diff --git a/.ci/Jenkinsfile_baseline_capture b/.ci/Jenkinsfile_baseline_capture index 791cacf7abb4c6..6993dc9e087f97 100644 --- a/.ci/Jenkinsfile_baseline_capture +++ b/.ci/Jenkinsfile_baseline_capture @@ -12,7 +12,17 @@ kibanaPipeline(timeoutMinutes: 120) { ]) { parallel([ 'oss-baseline': { - workers.ci(name: 'oss-baseline', size: 'l', ramDisk: true, runErrorReporter: false) { + workers.ci(name: 'oss-baseline', size: 'l', ramDisk: true, runErrorReporter: false, bootstrapped: false) { + // bootstrap ourselves, but with the env needed to upload the ts refs cache + withGcpServiceAccount.fromVaultSecret('secret/kibana-issues/dev/ci-artifacts-key', 'value') { + withEnv([ + 'BUILD_TS_REFS_CACHE_ENABLE=true', + 'BUILD_TS_REFS_CACHE_CAPTURE=true' + ]) { + kibanaPipeline.doSetup() + } + } + kibanaPipeline.functionalTestProcess('oss-baseline', './test/scripts/jenkins_baseline.sh')() } }, diff --git a/.eslintignore b/.eslintignore index 5513ad1320232c..ea8ab55ad77269 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,7 +22,7 @@ snapshots.js /src/core/lib/kbn_internal_native_observable /src/legacy/plugin_discovery/plugin_pack/__tests__/fixtures/plugins/broken /src/plugins/data/common/es_query/kuery/ast/_generated_/** -/src/plugins/vis_type_timelion/public/_generated_/** +/src/plugins/vis_type_timelion/common/_generated_/** /x-pack/legacy/plugins/**/__tests__/fixtures/** /x-pack/plugins/apm/e2e/tmp/* /x-pack/plugins/canvas/canvas_plugin diff --git a/.stylelintrc b/.stylelintrc index 29c1f4b552b48a..26431cfee6f1d7 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -32,6 +32,7 @@ rules: - function - return - for + - at-root comment-no-empty: true no-duplicate-at-import-rules: true no-duplicate-selectors: true diff --git a/common/graphql/introspection.json b/common/graphql/introspection.json deleted file mode 100644 index 3ebb95e26da8d5..00000000000000 --- a/common/graphql/introspection.json +++ /dev/null @@ -1,2650 +0,0 @@ -{ - "__schema": { - "queryType": { "name": "Query" }, - "mutationType": null, - "subscriptionType": null, - "types": [ - { - "kind": "OBJECT", - "name": "Query", - "description": "", - "fields": [ - { - "name": "allPings", - "description": "Get a list of all recorded pings for all monitors", - "args": [ - { - "name": "sort", - "description": "", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - }, - { - "name": "size", - "description": "", - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "defaultValue": null - }, - { - "name": "monitorId", - "description": "", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - }, - { - "name": "status", - "description": "", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - }, - { - "name": "dateRangeStart", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "dateRangeEnd", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null } - }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "Ping", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "getDocCount", - "description": "Gets the number of documents in the target index", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "DocCount", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "getMonitors", - "description": "", - "args": [ - { - "name": "dateRangeStart", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "dateRangeEnd", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "filters", - "description": "", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - } - ], - "type": { "kind": "OBJECT", "name": "LatestMonitorsResult", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "getSnapshot", - "description": "", - "args": [ - { - "name": "dateRangeStart", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "dateRangeEnd", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "downCount", - "description": "", - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "defaultValue": null - }, - { - "name": "windowSize", - "description": "", - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "defaultValue": null - }, - { - "name": "filters", - "description": "", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - } - ], - "type": { "kind": "OBJECT", "name": "Snapshot", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "getMonitorChartsData", - "description": "", - "args": [ - { - "name": "monitorId", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "dateRangeStart", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "dateRangeEnd", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "OBJECT", "name": "MonitorChartEntry", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "getLatestMonitors", - "description": "", - "args": [ - { - "name": "dateRangeStart", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "dateRangeEnd", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "monitorId", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "Ping", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "getFilterBar", - "description": "", - "args": [ - { - "name": "dateRangeStart", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "dateRangeEnd", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - } - ], - "type": { "kind": "OBJECT", "name": "FilterBar", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "getErrorsList", - "description": "", - "args": [ - { - "name": "dateRangeStart", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "dateRangeEnd", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "filters", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "OBJECT", "name": "ErrorListItem", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "String", - "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Int", - "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. ", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "UnsignedInteger", - "description": "", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Ping", - "description": "A request sent from a monitor to a host", - "fields": [ - { - "name": "timestamp", - "description": "The timestamp of the ping's creation", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "beat", - "description": "The agent that recorded the ping", - "args": [], - "type": { "kind": "OBJECT", "name": "Beat", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "docker", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Docker", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "error", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Error", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "host", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Host", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "http", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "HTTP", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "icmp", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "ICMP", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "kubernetes", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Kubernetes", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "meta", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Meta", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "monitor", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Monitor", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "resolve", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Resolve", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "socks5", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Socks5", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tags", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tcp", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "TCP", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tls", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "TLS", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Beat", - "description": "An agent for recording a beat", - "fields": [ - { - "name": "hostname", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timezone", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Docker", - "description": "", - "fields": [ - { - "name": "id", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "image", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Error", - "description": "", - "fields": [ - { - "name": "code", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "message", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Host", - "description": "", - "fields": [ - { - "name": "architecture", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ip", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mac", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "os", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "OS", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "OS", - "description": "", - "fields": [ - { - "name": "family", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "kernel", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "platform", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "version", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "HTTP", - "description": "", - "fields": [ - { - "name": "response", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "StatusCode", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "rtt", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "HttpRTT", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "url", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "StatusCode", - "description": "", - "fields": [ - { - "name": "status_code", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "HttpRTT", - "description": "", - "fields": [ - { - "name": "content", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "response_header", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "total", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "validate", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "validate_body", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "write_request", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Duration", - "description": "The monitor's status for a ping", - "fields": [ - { - "name": "us", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ICMP", - "description": "", - "fields": [ - { - "name": "requests", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "rtt", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Kubernetes", - "description": "", - "fields": [ - { - "name": "container", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "KubernetesContainer", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "namespace", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "KubernetesNode", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pod", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "KubernetesPod", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "KubernetesContainer", - "description": "", - "fields": [ - { - "name": "image", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "KubernetesNode", - "description": "", - "fields": [ - { - "name": "name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "KubernetesPod", - "description": "", - "fields": [ - { - "name": "name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "uid", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Meta", - "description": "", - "fields": [ - { - "name": "cloud", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "MetaCloud", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MetaCloud", - "description": "", - "fields": [ - { - "name": "availability_zone", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "instance_id", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "instance_name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "machine_type", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "project_id", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "provider", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "region", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Monitor", - "description": "", - "fields": [ - { - "name": "duration", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "host", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "The id of the monitor", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ip", - "description": "The IP pinged by the monitor", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "The name of the protocol being monitored", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "scheme", - "description": "The protocol scheme of the monitored host", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the monitored host", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "The type of host being monitored", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Resolve", - "description": "", - "fields": [ - { - "name": "host", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ip", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "rtt", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Socks5", - "description": "", - "fields": [ - { - "name": "rtt", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "RTT", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RTT", - "description": "", - "fields": [ - { - "name": "connect", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "handshake", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "validate", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Duration", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TCP", - "description": "", - "fields": [ - { - "name": "port", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "rtt", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "RTT", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TLS", - "description": "", - "fields": [ - { - "name": "certificate_not_valid_after", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "certificate_not_valid_before", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "certificates", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "rtt", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "RTT", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DocCount", - "description": "", - "fields": [ - { - "name": "count", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "LatestMonitorsResult", - "description": "", - "fields": [ - { - "name": "monitors", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "OBJECT", "name": "LatestMonitor", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "LatestMonitor", - "description": "", - "fields": [ - { - "name": "key", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "MonitorKey", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ping", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Ping", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "upSeries", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "OBJECT", "name": "MonitorSeriesPoint", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "downSeries", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "OBJECT", "name": "MonitorSeriesPoint", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MonitorKey", - "description": "", - "fields": [ - { - "name": "id", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "port", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MonitorSeriesPoint", - "description": "", - "fields": [ - { - "name": "x", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "y", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Snapshot", - "description": "", - "fields": [ - { - "name": "up", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "down", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "trouble", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "total", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "histogram", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "OBJECT", "name": "HistogramSeries", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "HistogramSeries", - "description": "", - "fields": [ - { - "name": "monitorId", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "data", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "OBJECT", "name": "HistogramDataPoint", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "HistogramDataPoint", - "description": "", - "fields": [ - { - "name": "upCount", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "downCount", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "x", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "x0", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "y", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MonitorChartEntry", - "description": "", - "fields": [ - { - "name": "maxContent", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "maxResponse", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "maxValidate", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "maxTotal", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "maxWriteRequest", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "maxTcpRtt", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "maxDuration", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "minDuration", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "avgDuration", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "DataPoint", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "StatusData", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DataPoint", - "description": "", - "fields": [ - { - "name": "x", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "y", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Float", - "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point). ", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "StatusData", - "description": "", - "fields": [ - { - "name": "x", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "UnsignedInteger", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "up", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "down", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "total", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "FilterBar", - "description": "", - "fields": [ - { - "name": "id", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "port", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Int", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "scheme", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ErrorListItem", - "description": "", - "fields": [ - { - "name": "latestMessage", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "monitorId", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "count", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "statusCode", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Schema", - "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", - "fields": [ - { - "name": "types", - "description": "A list of all types supported by this server.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__Type", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "queryType", - "description": "The type that query operations will be rooted at.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__Type", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mutationType", - "description": "If this server supports mutation, the type that mutation operations will be rooted at.", - "args": [], - "type": { "kind": "OBJECT", "name": "__Type", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "subscriptionType", - "description": "If this server support subscription, the type that subscription operations will be rooted at.", - "args": [], - "type": { "kind": "OBJECT", "name": "__Type", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "directives", - "description": "A list of all directives supported by this server.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__Directive", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Type", - "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", - "fields": [ - { - "name": "kind", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "__TypeKind", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fields", - "description": null, - "args": [ - { - "name": "includeDeprecated", - "description": null, - "type": { "kind": "SCALAR", "name": "Boolean", "ofType": null }, - "defaultValue": "false" - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__Field", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "interfaces", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__Type", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "possibleTypes", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__Type", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "enumValues", - "description": null, - "args": [ - { - "name": "includeDeprecated", - "description": null, - "type": { "kind": "SCALAR", "name": "Boolean", "ofType": null }, - "defaultValue": "false" - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__EnumValue", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inputFields", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__InputValue", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ofType", - "description": null, - "args": [], - "type": { "kind": "OBJECT", "name": "__Type", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "__TypeKind", - "description": "An enum describing what kind of type a given `__Type` is.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "SCALAR", - "description": "Indicates this type is a scalar.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OBJECT", - "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INTERFACE", - "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNION", - "description": "Indicates this type is a union. `possibleTypes` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ENUM", - "description": "Indicates this type is an enum. `enumValues` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INPUT_OBJECT", - "description": "Indicates this type is an input object. `inputFields` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LIST", - "description": "Indicates this type is a list. `ofType` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NON_NULL", - "description": "Indicates this type is a non-null. `ofType` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Boolean", - "description": "The `Boolean` scalar type represents `true` or `false`.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Field", - "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", - "fields": [ - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "args", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__InputValue", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__Type", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isDeprecated", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deprecationReason", - "description": null, - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__InputValue", - "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", - "fields": [ - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__Type", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "defaultValue", - "description": "A GraphQL-formatted string representing the default value for this input value.", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__EnumValue", - "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", - "fields": [ - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isDeprecated", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deprecationReason", - "description": null, - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Directive", - "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", - "fields": [ - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "locations", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "__DirectiveLocation", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "args", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "__InputValue", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "onOperation", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "isDeprecated": true, - "deprecationReason": "Use `locations`." - }, - { - "name": "onFragment", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "isDeprecated": true, - "deprecationReason": "Use `locations`." - }, - { - "name": "onField", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "isDeprecated": true, - "deprecationReason": "Use `locations`." - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "__DirectiveLocation", - "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "QUERY", - "description": "Location adjacent to a query operation.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MUTATION", - "description": "Location adjacent to a mutation operation.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUBSCRIPTION", - "description": "Location adjacent to a subscription operation.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FIELD", - "description": "Location adjacent to a field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAGMENT_DEFINITION", - "description": "Location adjacent to a fragment definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAGMENT_SPREAD", - "description": "Location adjacent to a fragment spread.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INLINE_FRAGMENT", - "description": "Location adjacent to an inline fragment.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SCHEMA", - "description": "Location adjacent to a schema definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SCALAR", - "description": "Location adjacent to a scalar definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OBJECT", - "description": "Location adjacent to an object type definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FIELD_DEFINITION", - "description": "Location adjacent to a field definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ARGUMENT_DEFINITION", - "description": "Location adjacent to an argument definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INTERFACE", - "description": "Location adjacent to an interface definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNION", - "description": "Location adjacent to a union definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ENUM", - "description": "Location adjacent to an enum definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ENUM_VALUE", - "description": "Location adjacent to an enum value definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INPUT_OBJECT", - "description": "Location adjacent to an input object type definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INPUT_FIELD_DEFINITION", - "description": "Location adjacent to an input object field definition.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - } - ], - "directives": [ - { - "name": "skip", - "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", - "locations": ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], - "args": [ - { - "name": "if", - "description": "Skipped when true.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "defaultValue": null - } - ] - }, - { - "name": "include", - "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", - "locations": ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], - "args": [ - { - "name": "if", - "description": "Included when true.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Boolean", "ofType": null } - }, - "defaultValue": null - } - ] - }, - { - "name": "deprecated", - "description": "Marks an element of a GraphQL schema as no longer supported.", - "locations": ["FIELD_DEFINITION", "ENUM_VALUE"], - "args": [ - { - "name": "reason", - "description": "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted in [Markdown](https://daringfireball.net/projects/markdown/).", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": "\"No longer supported\"" - } - ] - } - ] - } -} diff --git a/dev_docs/assets/saved_object_vs_data_indices.png b/dev_docs/assets/saved_object_vs_data_indices.png new file mode 100644 index 00000000000000..e79a5cd848db1c Binary files /dev/null and b/dev_docs/assets/saved_object_vs_data_indices.png differ diff --git a/dev_docs/key_concepts/saved_objects.mdx b/dev_docs/key_concepts/saved_objects.mdx new file mode 100644 index 00000000000000..d89342765c8f19 --- /dev/null +++ b/dev_docs/key_concepts/saved_objects.mdx @@ -0,0 +1,74 @@ +--- +id: kibDevDocsSavedObjectsIntro +slug: /kibana-dev-docs/saved-objects-intro +title: Saved Objects +summary: Saved Objects are a key concept to understand when building a Kibana plugin. +date: 2021-02-02 +tags: ['kibana','dev', 'contributor', 'api docs'] +--- + +"Saved Objects" are developer defined, persisted entities, stored in the Kibana system index (which is also sometimes referred to as the `.kibana` index). +The Saved Objects service allows Kibana plugins to use Elasticsearch like a primary database. Think of it as an Object Document Mapper for Elasticsearch. + Some examples of Saved Object types are dashboards, lens, canvas workpads, index patterns, cases, ml jobs, and advanced settings. Some Saved Object types are + exposed to the user in the [Saved Object management UI](https://www.elastic.co/guide/en/kibana/current/managing-saved-objects.html), but not all. + +Developers create and manage their Saved Objects using the SavedObjectClient, while other data in Elasticsearch should be accessed via the data plugin's search +services. + +![image](../assets/saved_object_vs_data_indices.png) + + + + +## References + +In order to support import and export, and space-sharing capabilities, Saved Objects need to explicitly list any references they contain to other Saved Objects. +The parent should have a reference to it's children, not the other way around. That way when a "parent" is exported (or shared to a space), + all the "children" will be automatically included. However, when a "child" is exported, it will not include all "parents". + + + +## Migrations and Backward compatibility + +As your plugin evolves, you may need to change your Saved Object type in a breaking way (for example, changing the type of an attribtue, or removing +an attribute). If that happens, you should write a migration to upgrade the Saved Objects that existed prior to the change. + +. + +## Security + +Saved Objects can be secured using Kibana's Privileges model, unlike data that comes from data indices, which is secured using Elasticsearch's Privileges model. + +### Space awareness + +Saved Objects are "space aware". They exist in the space they were created in, and any spaces they have been shared with. + +### Feature controls and RBAC + +Feature controls provide another level of isolation and shareability for Saved Objects. Admins can give users and roles read, write or none permissions for each Saved Object type. + +### Object level security (OLS) + +OLS is an oft-requested feature that is not implemented yet. When it is, it will provide users with even more sharing and privacy flexibility. Individual +objects can be private to the user, shared with a selection of others, or made public. Much like how sharing Google Docs works. + +## Scalability + +By default all saved object types go into a single index. If you expect your saved object type to have a lot of unique fields, or if you expect there +to be many of them, you can have your objects go in a separate index by using the `indexPattern` field. Reporting and task manager are two +examples of features that use this capability. + +## Searchability + +Because saved objects are stored in system indices, they cannot be searched like other data can. If you see the phrase “[X] as data” it is +referring to this searching limitation. Users will not be able to create custom dashboards using saved object data, like they would for data stored +in Elasticsearch data indices. + +## Saved Objects by value + +Sometimes Saved Objects end up persisted inside another Saved Object. We call these Saved Objects “by value”, as opposed to "by + reference". If an end user creates a visualization and adds it to a dashboard without saving it to the visualization + library, the data ends up nested inside the dashboard Saved Object. This helps keep the visualization library smaller. It also avoids + issues with edits propagating - since an entity can only exist in a single place. + Note that from the end user stand point, we don’t use these terms “by reference” and “by value”. + diff --git a/dev_docs/tutorials/saved_objects.mdx b/dev_docs/tutorials/saved_objects.mdx new file mode 100644 index 00000000000000..bd7d231218af1f --- /dev/null +++ b/dev_docs/tutorials/saved_objects.mdx @@ -0,0 +1,250 @@ +--- +id: kibDevTutorialSavedObject +slug: /kibana-dev-docs/tutorial/saved-objects +title: Register a new saved object type +summary: Learn how to register a new saved object type. +date: 2021-02-05 +tags: ['kibana','onboarding', 'dev', 'architecture', 'tutorials'] +--- + +Saved Object type definitions should be defined in their own `my_plugin/server/saved_objects` directory. + +The folder should contain a file per type, named after the snake_case name of the type, and an index.ts file exporting all the types. + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType } from 'src/core/server'; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', [1] + hidden: false, + namespaceType: 'single', + mappings: { + dynamic: false, + properties: { + description: { + type: 'text', + }, + hits: { + type: 'integer', + }, + }, + }, + migrations: { + '1.0.0': migratedashboardVisualizationToV1, + '2.0.0': migratedashboardVisualizationToV2, + }, +}; +``` + +[1] Since the name of a Saved Object type forms part of the url path for the public Saved Objects HTTP API, +these should follow our API URL path convention and always be written as snake case. + +**src/plugins/my_plugin/server/saved_objects/index.ts** + +```ts +export { dashboardVisualization } from './dashboard_visualization'; +export { dashboard } from './dashboard'; +``` + +**src/plugins/my_plugin/server/plugin.ts** + +```ts +import { dashboard, dashboardVisualization } from './saved_objects'; + +export class MyPlugin implements Plugin { + setup({ savedObjects }) { + savedObjects.registerType(dashboard); + savedObjects.registerType(dashboardVisualization); + } +} +``` + +## Mappings + +Each Saved Object type can define its own Elasticsearch field mappings. Because multiple Saved Object +types can share the same index, mappings defined by a type will be nested under a top-level field that matches the type name. + +For example, the mappings defined by the dashboard_visualization Saved Object type: + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType } from 'src/core/server'; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', + ... + mappings: { + properties: { + dynamic: false, + description: { + type: 'text', + }, + hits: { + type: 'integer', + }, + }, + }, + migrations: { ... }, +}; +``` + +Will result in the following mappings being applied to the .kibana index: + +```ts +{ + "mappings": { + "dynamic": "strict", + "properties": { + ... + "dashboard_vizualization": { + "dynamic": false, + "properties": { + "description": { + "type": "text", + }, + "hits": { + "type": "integer", + }, + }, + } + } + } +} +``` +Do not use field mappings like you would use data types for the columns of a SQL database. Instead, field mappings are analogous to a +SQL index. Only specify field mappings for the fields you wish to search on or query. By specifying `dynamic: false` + in any level of your mappings, Elasticsearch will accept and store any other fields even if they are not specified in your mappings. + +Since Elasticsearch has a default limit of 1000 fields per index, plugins should carefully consider the +fields they add to the mappings. Similarly, Saved Object types should never use `dynamic: true` as this can cause an arbitrary + amount of fields to be added to the .kibana index. + + ## References + +Declare by adding an id, type and name to the + `references` array. + +```ts +router.get( + { path: '/some-path', validate: false }, + async (context, req, res) => { + const object = await context.core.savedObjects.client.create( + 'dashboard', + { + title: 'my dashboard', + panels: [ + { visualization: 'vis1' }, [1] + ], + indexPattern: 'indexPattern1' + }, + { references: [ + { id: '...', type: 'visualization', name: 'vis1' }, + { id: '...', type: 'index_pattern', name: 'indexPattern1' }, + ] + } + ) + ... + } +); +``` +[1] Note how `dashboard.panels[0].visualization` stores the name property of the reference (not the id directly) to be able to uniquely +identify this reference. This guarantees that the id the reference points to always remains up to date. If a + visualization id was directly stored in `dashboard.panels[0].visualization` there is a risk that this id gets updated without + updating the reference in the references array. + +## Writing migrations + +Saved Objects support schema changes between Kibana versions, which we call migrations. Migrations are + applied when a Kibana installation is upgraded from one version to the next, when exports are imported via + the Saved Objects Management UI, or when a new object is created via the HTTP API. + +Each Saved Object type may define migrations for its schema. Migrations are specified by the Kibana version number, receive an input document, + and must return the fully migrated document to be persisted to Elasticsearch. + +Let’s say we want to define two migrations: - In version 1.1.0, we want to drop the subtitle field and append it to the title - In version + 1.4.0, we want to add a new id field to every panel with a newly generated UUID. + +First, the current mappings should always reflect the latest or "target" schema. Next, we should define a migration function for each step in the schema evolution: + +**src/plugins/my_plugin/server/saved_objects/dashboard_visualization.ts** + +```ts +import { SavedObjectsType, SavedObjectMigrationFn } from 'src/core/server'; +import uuid from 'uuid'; + +interface DashboardVisualizationPre110 { + title: string; + subtitle: string; + panels: Array<{}>; +} +interface DashboardVisualization110 { + title: string; + panels: Array<{}>; +} + +interface DashboardVisualization140 { + title: string; + panels: Array<{ id: string }>; +} + +const migrateDashboardVisualization110: SavedObjectMigrationFn< + DashboardVisualizationPre110, [1] + DashboardVisualization110 +> = (doc) => { + const { subtitle, ...attributesWithoutSubtitle } = doc.attributes; + return { + ...doc, [2] + attributes: { + ...attributesWithoutSubtitle, + title: `${doc.attributes.title} - ${doc.attributes.subtitle}`, + }, + }; +}; + +const migrateDashboardVisualization140: SavedObjectMigrationFn< + DashboardVisualization110, + DashboardVisualization140 +> = (doc) => { + const outPanels = doc.attributes.panels?.map((panel) => { + return { ...panel, id: uuid.v4() }; + }); + return { + ...doc, + attributes: { + ...doc.attributes, + panels: outPanels, + }, + }; +}; + +export const dashboardVisualization: SavedObjectsType = { + name: 'dashboard_visualization', [1] + /** ... */ + migrations: { + // Takes a pre 1.1.0 doc, and converts it to 1.1.0 + '1.1.0': migrateDashboardVisualization110, + + // Takes a 1.1.0 doc, and converts it to 1.4.0 + '1.4.0': migrateDashboardVisualization140, [3] + }, +}; +``` +[1] It is useful to define an interface for each version of the schema. This allows TypeScript to ensure that you are properly handling the input and output + types correctly as the schema evolves. + +[2] Returning a shallow copy is necessary to avoid type errors when using different types for the input and output shape. + +[3] Migrations do not have to be defined for every version. The version number of a migration must always be the earliest Kibana version + in which this migration was released. So if you are creating a migration which will + be part of the v7.10.0 release, but will also be backported and released as v7.9.3, the migration version should be: 7.9.3. + + Migrations should be written defensively, an exception in a migration function will prevent a Kibana upgrade from succeeding and will cause downtime for our users. + Having said that, if a + document is encountered that is not in the expected shape, migrations are encouraged to throw an exception to abort the upgrade. In most scenarios, it is better to + fail an upgrade than to silently ignore a corrupt document which can cause unexpected behaviour at some future point in time. + +It is critical that you have extensive tests to ensure that migrations behave as expected with all possible input documents. Given how simple it is to test all the branch +conditions in a migration function and the high impact of a bug in this code, there’s really no reason not to aim for 100% test code coverage. diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 613f2d0fbf20cf..5564b4cdcf79da 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -301,6 +301,10 @@ which will load the visualization's editor. |To access an elasticsearch instance that has live data you have two options: +|{kib-repo}blob/{branch}/x-pack/plugins/banners/README.md[banners] +|Allow to add a header banner that will be displayed on every page of the Kibana application + + |{kib-repo}blob/{branch}/x-pack/plugins/beats_management/readme.md[beatsManagement] |Notes: Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc index 92a624649d3c50..6361b3c921128a 100644 --- a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -800,7 +800,7 @@ However, there are some minor changes: * The `schema.isNamespaceAgnostic` property has been renamed: `SavedObjectsType.namespaceType`. It no longer accepts a boolean but -instead an enum of `single`, `multiple`, or `agnostic` (see +instead an enum of `single`, `multiple`, `multiple-isolated`, or `agnostic` (see {kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md[SavedObjectsNamespaceType]). * The `schema.indexPattern` was accepting either a `string` or a `(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.md index 663b326193de56..2d465745c436b5 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.md @@ -67,6 +67,7 @@ core.chrome.setHelpExtension(elem => { | [setBreadcrumbs(newBreadcrumbs)](./kibana-plugin-core-public.chromestart.setbreadcrumbs.md) | Override the current set of breadcrumbs | | [setBreadcrumbsAppendExtension(breadcrumbsAppendExtension)](./kibana-plugin-core-public.chromestart.setbreadcrumbsappendextension.md) | Mount an element next to the last breadcrumb | | [setCustomNavLink(newCustomNavLink)](./kibana-plugin-core-public.chromestart.setcustomnavlink.md) | Override the current set of custom nav link | +| [setHeaderBanner(headerBanner)](./kibana-plugin-core-public.chromestart.setheaderbanner.md) | Set the banner that will appear on top of the chrome header. | | [setHelpExtension(helpExtension)](./kibana-plugin-core-public.chromestart.sethelpextension.md) | Override the current set of custom help content | | [setHelpSupportUrl(url)](./kibana-plugin-core-public.chromestart.sethelpsupporturl.md) | Override the default support URL shown in the help menu | | [setIsVisible(isVisible)](./kibana-plugin-core-public.chromestart.setisvisible.md) | Set the temporary visibility for the chrome. This does nothing if the chrome is hidden by default and should be used to hide the chrome for things like full-screen modes with an exit button. | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md new file mode 100644 index 00000000000000..02a2fa65ed4781 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.setheaderbanner.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [setHeaderBanner](./kibana-plugin-core-public.chromestart.setheaderbanner.md) + +## ChromeStart.setHeaderBanner() method + +Set the banner that will appear on top of the chrome header. + +Signature: + +```typescript +setHeaderBanner(headerBanner?: ChromeUserBanner): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| headerBanner | ChromeUserBanner | | + +Returns: + +`void` + +## Remarks + +Using `undefined` when invoking this API will remove the banner. + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md new file mode 100644 index 00000000000000..7a77fdc6223de9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.content.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) > [content](./kibana-plugin-core-public.chromeuserbanner.content.md) + +## ChromeUserBanner.content property + +Signature: + +```typescript +content: MountPoint; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md new file mode 100644 index 00000000000000..8617c5c4d2b12f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromeuserbanner.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) + +## ChromeUserBanner interface + + +Signature: + +```typescript +export interface ChromeUserBanner +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [content](./kibana-plugin-core-public.chromeuserbanner.content.md) | MountPoint<HTMLDivElement> | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index fd46a8a0f82c12..017e3ec57d3404 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -91,7 +91,9 @@ readonly links: { readonly scriptAggs: string; readonly painless: string; readonly painlessApi: string; + readonly painlessLangSpec: string; readonly painlessSyntax: string; + readonly painlessWalkthrough: string; readonly luceneExpressions: string; }; readonly indexPatterns: { @@ -131,6 +133,7 @@ readonly links: { openIndex: string; putComponentTemplate: string; painlessExecute: string; + painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putWatch: string; updateTransform: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index f4bce8b51ebb1a..dc6804b0630bd4 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,4 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | - +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
putComponentTemplateMetadata: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
} | | diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index e307b5c9971b0b..ba48011ef84e08 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -56,6 +56,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeRecentlyAccessed](./kibana-plugin-core-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-core-public.chromerecentlyaccessed.md) for recently accessed history. | | [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-core-public.chromerecentlyaccessedhistoryitem.md) | | | [ChromeStart](./kibana-plugin-core-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ChromeUserBanner](./kibana-plugin-core-public.chromeuserbanner.md) | | | [CoreSetup](./kibana-plugin-core-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | @@ -167,7 +168,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | | [SavedObjectsImportWarning](./kibana-plugin-core-public.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-public.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-public.savedobjectsimportactionrequiredwarning.md) for more details. | -| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | +| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. | | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.maxwidth.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.maxwidth.md new file mode 100644 index 00000000000000..0ae888f9cb3613 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.maxwidth.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayModalOpenOptions](./kibana-plugin-core-public.overlaymodalopenoptions.md) > [maxWidth](./kibana-plugin-core-public.overlaymodalopenoptions.maxwidth.md) + +## OverlayModalOpenOptions.maxWidth property + +Signature: + +```typescript +maxWidth?: boolean | number | string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.md index 5c0ef8fb1ec863..5307a8357a8142 100644 --- a/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.overlaymodalopenoptions.md @@ -18,4 +18,5 @@ export interface OverlayModalOpenOptions | ["data-test-subj"](./kibana-plugin-core-public.overlaymodalopenoptions._data-test-subj_.md) | string | | | [className](./kibana-plugin-core-public.overlaymodalopenoptions.classname.md) | string | | | [closeButtonAriaLabel](./kibana-plugin-core-public.overlaymodalopenoptions.closebuttonarialabel.md) | string | | +| [maxWidth](./kibana-plugin-core-public.overlaymodalopenoptions.maxwidth.md) | boolean | number | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 8bd87c2f6ea35f..69cfb818561e5d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -23,9 +23,11 @@ export interface SavedObjectsFindOptions | [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number | | +| [pit](./kibana-plugin-core-public.savedobjectsfindoptions.pit.md) | SavedObjectsPitParams | Search against a specific Point In Time (PIT) that you've opened with . | | [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | | [rootSearchFields](./kibana-plugin-core-public.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-public.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | +| [searchAfter](./kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md) | unknown[] | Use the sort values from the previous page to retrieve the next page of results. | | [searchFields](./kibana-plugin-core-public.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | string | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md new file mode 100644 index 00000000000000..2284a4d8d210d8 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.pit.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [pit](./kibana-plugin-core-public.savedobjectsfindoptions.pit.md) + +## SavedObjectsFindOptions.pit property + +Search against a specific Point In Time (PIT) that you've opened with . + +Signature: + +```typescript +pit?: SavedObjectsPitParams; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md new file mode 100644 index 00000000000000..99ca2c34e77be9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [searchAfter](./kibana-plugin-core-public.savedobjectsfindoptions.searchafter.md) + +## SavedObjectsFindOptions.searchAfter property + +Use the sort values from the previous page to retrieve the next page of results. + +Signature: + +```typescript +searchAfter?: unknown[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md index f2205d2cee4240..cf5e6cb29a5329 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## SavedObjectsNamespaceType type -The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md index 0b7e6467667cb2..6fcfae559dd33f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md @@ -23,6 +23,7 @@ export interface UiSettingsParams | [name](./kibana-plugin-core-public.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-public.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-public.uisettingsparams.options.md) | string[] | array of permitted values for this setting | +| [order](./kibana-plugin-core-public.uisettingsparams.order.md) | number | index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. settings without order defined will be displayed last and ordered by name | | [readonly](./kibana-plugin-core-public.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-core-public.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [schema](./kibana-plugin-core-public.uisettingsparams.schema.md) | Type<T> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md new file mode 100644 index 00000000000000..d93aaeb9046168 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.order.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) > [order](./kibana-plugin-core-public.uisettingsparams.order.md) + +## UiSettingsParams.order property + +index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. + + settings without order defined will be displayed last and ordered by name + +Signature: + +```typescript +order?: number; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md b/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md index 5753704ccfe037..65e6264ea1e08a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingstype.md @@ -9,5 +9,5 @@ UI element type to represent the settings. Signature: ```typescript -export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 5fe5eda7a81729..3ec63840a67cba 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -155,6 +155,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) | | | [SavedObjectsClientProviderOptions](./kibana-plugin-core-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | | [SavedObjectsClientWrapperOptions](./kibana-plugin-core-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | +| [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) | | | [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) | | @@ -188,6 +189,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsMappingProperties](./kibana-plugin-core-server.savedobjectsmappingproperties.md) | Describe the fields of a [saved object type](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md). | | [SavedObjectsMigrationLogger](./kibana-plugin-core-server.savedobjectsmigrationlogger.md) | | | [SavedObjectsMigrationVersion](./kibana-plugin-core-server.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) | | +| [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) | | +| [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) | | | [SavedObjectsRawDoc](./kibana-plugin-core-server.savedobjectsrawdoc.md) | A raw document as represented directly in the saved object index. | | [SavedObjectsRawDocParseOptions](./kibana-plugin-core-server.savedobjectsrawdocparseoptions.md) | Options that can be specified when using the saved objects serializer to parse a raw document. | | [SavedObjectsRemoveReferencesToOptions](./kibana-plugin-core-server.savedobjectsremovereferencestooptions.md) | | @@ -301,11 +305,12 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md) | Describes the factory used to create instances of the Saved Objects Client. | | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | +| [SavedObjectsClosePointInTimeOptions](./kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md) | | | [SavedObjectsExportTransform](./kibana-plugin-core-server.savedobjectsexporttransform.md) | Transformation function used to mutate the exported objects of the associated type.A type's export transform function will be executed once per user-initiated export, for all objects of that type. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. | | [SavedObjectsImportWarning](./kibana-plugin-core-server.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-server.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-server.savedobjectsimportactionrequiredwarning.md) for more details. | -| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | +| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. | | [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) | Describes Saved Object documents from Kibana < 7.0.0 which don't have a references root property defined. This type should only be used in migrations. | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md index 3a5e84ffdc3724..268dcdd77d6b47 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md @@ -11,8 +11,9 @@ core: { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md index 5300c85cf94064..54d85910f823c1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exporter: ISavedObjectsExporter;
importer: ISavedObjectsImporter;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | +| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract;
getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter;
getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md new file mode 100644 index 00000000000000..2a30693f4da84a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) + +## SavedObjectMigrationContext.convertToMultiNamespaceTypeVersion property + +The version in which this object type is being converted to a multi-namespace type + +Signature: + +```typescript +convertToMultiNamespaceTypeVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md index 901f2dde0944ce..c8a291e5028453 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md @@ -16,5 +16,7 @@ export interface SavedObjectMigrationContext | Property | Type | Description | | --- | --- | --- | +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) | string | The version in which this object type is being converted to a multi-namespace type | | [log](./kibana-plugin-core-server.savedobjectmigrationcontext.log.md) | SavedObjectsMigrationLogger | logger instance to be used by the migration handler | +| [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) | string | The migration version that this migration function is defined for | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md new file mode 100644 index 00000000000000..7b20ae41048f66 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) + +## SavedObjectMigrationContext.migrationVersion property + +The migration version that this migration function is defined for + +Signature: + +```typescript +migrationVersion: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md new file mode 100644 index 00000000000000..dc765260a08ca7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.closepointintime.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [closePointInTime](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) + +## SavedObjectsClient.closePointInTime() method + +Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +Signature: + +```typescript +closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | | +| options | SavedObjectsClosePointInTimeOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index da1f4d029ea2ba..887f7f7d93a873 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -30,11 +30,13 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | +| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | +| [openPointInTimeForType(type, options)](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md new file mode 100644 index 00000000000000..56c1d6d1ddc331 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [openPointInTimeForType](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) + +## SavedObjectsClient.openPointInTimeForType() method + +Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned `id` can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT. + +Signature: + +```typescript +openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | string[] | | +| options | SavedObjectsOpenPointInTimeOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md new file mode 100644 index 00000000000000..27432a8805b06d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeOptions](./kibana-plugin-core-server.savedobjectsclosepointintimeoptions.md) + +## SavedObjectsClosePointInTimeOptions type + + +Signature: + +```typescript +export declare type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md new file mode 100644 index 00000000000000..43ecd1298d5d97 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) + +## SavedObjectsClosePointInTimeResponse interface + + +Signature: + +```typescript +export interface SavedObjectsClosePointInTimeResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [num\_freed](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md) | number | The number of search contexts that have been successfully closed. | +| [succeeded](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md) | boolean | If true, all search contexts associated with the PIT id are successfully closed. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md new file mode 100644 index 00000000000000..b64932fcee8f66 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) > [num\_freed](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.num_freed.md) + +## SavedObjectsClosePointInTimeResponse.num\_freed property + +The number of search contexts that have been successfully closed. + +Signature: + +```typescript +num_freed: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md new file mode 100644 index 00000000000000..225a549a4cf591 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) > [succeeded](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.succeeded.md) + +## SavedObjectsClosePointInTimeResponse.succeeded property + +If true, all search contexts associated with the PIT id are successfully closed. + +Signature: + +```typescript +succeeded: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md index 5e959bbee7beb9..3f3d708c590ee8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter._constructor_.md @@ -9,10 +9,11 @@ Constructs a new instance of the `SavedObjectsExporter` class Signature: ```typescript -constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { +constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }); ``` @@ -20,5 +21,5 @@ constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { | Parameter | Type | Description | | --- | --- | --- | -| { savedObjectsClient, typeRegistry, exportSizeLimit, } | {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
} | | +| { savedObjectsClient, typeRegistry, exportSizeLimit, logger, } | {
savedObjectsClient: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exportSizeLimit: number;
logger: Logger;
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md index 727108b824c848..ce23e91633b078 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsexporter.md @@ -15,7 +15,7 @@ export declare class SavedObjectsExporter | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)({ savedObjectsClient, typeRegistry, exportSizeLimit, })](./kibana-plugin-core-server.savedobjectsexporter._constructor_.md) | | Constructs a new instance of the SavedObjectsExporter class | +| [(constructor)({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, })](./kibana-plugin-core-server.savedobjectsexporter._constructor_.md) | | Constructs a new instance of the SavedObjectsExporter class | ## Properties diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index d393d579dbdd24..6f7c05ea469bc6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -23,9 +23,11 @@ export interface SavedObjectsFindOptions | [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number | | +| [pit](./kibana-plugin-core-server.savedobjectsfindoptions.pit.md) | SavedObjectsPitParams | Search against a specific Point In Time (PIT) that you've opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). | | [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | | [rootSearchFields](./kibana-plugin-core-server.savedobjectsfindoptions.rootsearchfields.md) | string[] | The fields to perform the parsed query against. Unlike the searchFields argument, these are expected to be root fields and will not be modified. If used in conjunction with searchFields, both are concatenated together. | | [search](./kibana-plugin-core-server.savedobjectsfindoptions.search.md) | string | Search documents using the Elasticsearch Simple Query String syntax. See Elasticsearch Simple Query String query argument for more information | +| [searchAfter](./kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md) | unknown[] | Use the sort values from the previous page to retrieve the next page of results. | | [searchFields](./kibana-plugin-core-server.savedobjectsfindoptions.searchfields.md) | string[] | The fields to perform the parsed query against. See Elasticsearch Simple Query String fields argument for more information | | [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md new file mode 100644 index 00000000000000..fac333227088c0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.pit.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [pit](./kibana-plugin-core-server.savedobjectsfindoptions.pit.md) + +## SavedObjectsFindOptions.pit property + +Search against a specific Point In Time (PIT) that you've opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +Signature: + +```typescript +pit?: SavedObjectsPitParams; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md new file mode 100644 index 00000000000000..6364370948976e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [searchAfter](./kibana-plugin-core-server.savedobjectsfindoptions.searchafter.md) + +## SavedObjectsFindOptions.searchAfter property + +Use the sort values from the previous page to retrieve the next page of results. + +Signature: + +```typescript +searchAfter?: unknown[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md index 4ed069d1598fe4..fd56e8ce40e241 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md @@ -20,6 +20,7 @@ export interface SavedObjectsFindResponse | --- | --- | --- | | [page](./kibana-plugin-core-server.savedobjectsfindresponse.page.md) | number | | | [per\_page](./kibana-plugin-core-server.savedobjectsfindresponse.per_page.md) | number | | +| [pit\_id](./kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md) | string | | | [saved\_objects](./kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md) | Array<SavedObjectsFindResult<T>> | | | [total](./kibana-plugin-core-server.savedobjectsfindresponse.total.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md new file mode 100644 index 00000000000000..dc4f9b509d606a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) > [pit\_id](./kibana-plugin-core-server.savedobjectsfindresponse.pit_id.md) + +## SavedObjectsFindResponse.pit\_id property + +Signature: + +```typescript +pit_id?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md index e455074a7d11bb..0f8e9c59236bbc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md @@ -16,4 +16,5 @@ export interface SavedObjectsFindResult extends SavedObject | Property | Type | Description | | --- | --- | --- | | [score](./kibana-plugin-core-server.savedobjectsfindresult.score.md) | number | The Elasticsearch _score of this result. | +| [sort](./kibana-plugin-core-server.savedobjectsfindresult.sort.md) | unknown[] | The Elasticsearch sort value of this result. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md new file mode 100644 index 00000000000000..3cc02c404c8d71 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.sort.md @@ -0,0 +1,41 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) > [sort](./kibana-plugin-core-server.savedobjectsfindresult.sort.md) + +## SavedObjectsFindResult.sort property + +The Elasticsearch `sort` value of this result. + +Signature: + +```typescript +sort?: unknown[]; +``` + +## Remarks + +This can be passed directly to the `searchAfter` param in the [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) in order to page through large numbers of hits. It is recommended you use this alongside a Point In Time (PIT) that was opened with [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md). + +## Example + + +```ts +const { id } = await savedObjectsClient.openPointInTimeForType('visualization'); +const page1 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit, +}); +const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; +const page2 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit: { id: page1.pit_id }, + searchAfter: lastHit.sort, +}); +await savedObjectsClient.closePointInTime(page2.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md index 9075a780bd2c79..01a712aa89aa9a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## SavedObjectsNamespaceType type -The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md new file mode 100644 index 00000000000000..57752318cb96a9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) > [keepAlive](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md) + +## SavedObjectsOpenPointInTimeOptions.keepAlive property + +Optionally specify how long ES should keep the PIT alive until the next request. Defaults to `5m`. + +Signature: + +```typescript +keepAlive?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md new file mode 100644 index 00000000000000..46516be2329e9c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) + +## SavedObjectsOpenPointInTimeOptions interface + + +Signature: + +```typescript +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [keepAlive](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.keepalive.md) | string | Optionally specify how long ES should keep the PIT alive until the next request. Defaults to 5m. | +| [preference](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md) | string | An optional ES preference value to be used for the query. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md new file mode 100644 index 00000000000000..7a9f3a49e8663e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeOptions](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.md) > [preference](./kibana-plugin-core-server.savedobjectsopenpointintimeoptions.preference.md) + +## SavedObjectsOpenPointInTimeOptions.preference property + +An optional ES preference value to be used for the query. + +Signature: + +```typescript +preference?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md new file mode 100644 index 00000000000000..66387e5b3b89fe --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) > [id](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md) + +## SavedObjectsOpenPointInTimeResponse.id property + +PIT ID returned from ES. + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md new file mode 100644 index 00000000000000..c4be2692763a5f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsOpenPointInTimeResponse](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.md) + +## SavedObjectsOpenPointInTimeResponse interface + + +Signature: + +```typescript +export interface SavedObjectsOpenPointInTimeResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectsopenpointintimeresponse.id.md) | string | PIT ID returned from ES. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md new file mode 100644 index 00000000000000..cb4d4a65727d7e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) > [id](./kibana-plugin-core-server.savedobjectspitparams.id.md) + +## SavedObjectsPitParams.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md new file mode 100644 index 00000000000000..1011a908f210ac --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.keepalive.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) > [keepAlive](./kibana-plugin-core-server.savedobjectspitparams.keepalive.md) + +## SavedObjectsPitParams.keepAlive property + +Signature: + +```typescript +keepAlive?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md new file mode 100644 index 00000000000000..7bffca7cda281f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectspitparams.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsPitParams](./kibana-plugin-core-server.savedobjectspitparams.md) + +## SavedObjectsPitParams interface + + +Signature: + +```typescript +export interface SavedObjectsPitParams +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectspitparams.id.md) | string | | +| [keepAlive](./kibana-plugin-core-server.savedobjectspitparams.keepalive.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md new file mode 100644 index 00000000000000..8f9dca35fa3629 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.closepointintime.md @@ -0,0 +1,58 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [closePointInTime](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) + +## SavedObjectsRepository.closePointInTime() method + +Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using `openPointInTimeForType`. + +Signature: + +```typescript +closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | | +| options | SavedObjectsClosePointInTimeOptions | | + +Returns: + +`Promise` + +{promise} - [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) + +## Remarks + +While the `keepAlive` that is provided will cause a PIT to automatically close, it is highly recommended to explicitly close a PIT when you are done with it in order to avoid consuming unneeded resources in Elasticsearch. + +## Example + + +```ts +const repository = coreStart.savedObjects.createInternalRepository(); + +const { id } = await repository.openPointInTimeForType( + type: 'index-pattern', + { keepAlive: '2m' }, +); + +const response = await repository.find({ + type: 'index-pattern', + search: 'foo*', + sortField: 'name', + sortOrder: 'desc', + pit: { + id: 'abc123', + keepAlive: '2m', + }, + searchAfter: [1234, 'abcd'], +}); + +await repository.closePointInTime(response.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 4d13fea12572c7..632d9c279cb882 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -20,6 +20,7 @@ export declare class SavedObjectsRepository | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | +| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using openPointInTimeForType. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | @@ -27,6 +28,7 @@ export declare class SavedObjectsRepository | [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFields, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields (by one by default). Creates the document if one doesn't exist for the given id. | +| [openPointInTimeForType(type, { keepAlive, preference })](./kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to SavedObjects.find to search against that PIT. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md new file mode 100644 index 00000000000000..63956ebee68f7b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md @@ -0,0 +1,57 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [openPointInTimeForType](./kibana-plugin-core-server.savedobjectsrepository.openpointintimefortype.md) + +## SavedObjectsRepository.openPointInTimeForType() method + +Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + +Signature: + +```typescript +openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | string[] | | +| { keepAlive, preference } | SavedObjectsOpenPointInTimeOptions | | + +Returns: + +`Promise` + +{promise} - { id: string } + +## Example + + +```ts +const repository = coreStart.savedObjects.createInternalRepository(); + +const { id } = await repository.openPointInTimeForType( + type: 'index-pattern', + { keepAlive: '2m' }, +); +const page1 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit, +}); + +const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; +const page2 = await savedObjectsClient.find({ + type: 'visualization', + sortField: 'updated_at', + sortOrder: 'asc', + pit: { id: page1.pit_id }, + searchAfter: lastHit.sort, +}); + +await savedObjectsClient.closePointInTime(page2.pit_id); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md new file mode 100644 index 00000000000000..2e73d6ba2e1a9f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) + +## SavedObjectsResolveResponse.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index cfb309da0a716f..ffcf15dbc80c7c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -15,6 +15,7 @@ export interface SavedObjectsResolveResponse | Property | Type | Description | | --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | | [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | | [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md index 064bd0b35699df..20346919fc652e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md @@ -4,13 +4,13 @@ ## SavedObjectsType.convertToMultiNamespaceTypeVersion property -If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. +If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version. Requirements: -1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) +1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) -Example of a single-namespace type in 7.10: +Example of a single-namespace type in 7.12: ```ts { @@ -21,7 +21,19 @@ Example of a single-namespace type in 7.10: } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: + +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { @@ -29,11 +41,11 @@ Example after converting to a multi-namespace type in 7.11: hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index eacad53be39fe0..d882938d731c8c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -19,7 +19,7 @@ This is only internal for now, and will only be public when we expose the regist | Property | Type | Description | | --- | --- | --- | | [convertToAliasScript](./kibana-plugin-core-server.savedobjectstype.converttoaliasscript.md) | string | If defined, will be used to convert the type to an alias. | -| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.10: +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.12: ```ts { name: 'foo', @@ -29,18 +29,29 @@ This is only internal for now, and will only be public when we expose the regist } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { name: 'foo', hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. | +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. | | [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | boolean | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an extraType when creating the repository.See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md). | | [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | string | If defined, the type instances will be stored in the given index instead of the default one. | | [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md index 6532c5251d816f..0ff07ae2804ff8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md @@ -4,7 +4,7 @@ ## SavedObjectTypeRegistry.isMultiNamespace() method -Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered +Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to `false` if the type is not registered Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md new file mode 100644 index 00000000000000..ee240268f9d67a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isShareable](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) + +## SavedObjectTypeRegistry.isShareable() method + +Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered + +Signature: + +```typescript +isShareable(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md index 55ad7ca137de0a..0f2de8c8ef9b33 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -23,8 +23,9 @@ export declare class SavedObjectTypeRegistry | [getVisibleTypes()](./kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md) | | Returns all visible [types](./kibana-plugin-core-server.savedobjectstype.md).A visible type is a type that doesn't explicitly define hidden=true during registration. | | [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the hidden property for given type, or false if the type is not registered. | | [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the management.importableAndExportable property for given type, or false if the type is not registered or does not define a management section. | -| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | +| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to false if the type is not registered | | [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns whether the type is namespace-agnostic (global); resolves to false if the type is not registered | +| [isShareable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | | [isSingleNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) | | Returns whether the type is single-namespace (isolated); resolves to true if the type is not registered | | [registerType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.registertype.md) | | Register a [type](./kibana-plugin-core-server.savedobjectstype.md) inside the registry. A type can only be registered once. subsequent calls with the same type name will throw an error. | diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md index 1629e774255252..599c4e3ad6319e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.hits.md @@ -22,7 +22,7 @@ hits: { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.md index b53cbf0d87f240..cbaab4632014d9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.searchresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.md @@ -18,7 +18,8 @@ export interface SearchResponse | [\_scroll\_id](./kibana-plugin-core-server.searchresponse._scroll_id.md) | string | | | [\_shards](./kibana-plugin-core-server.searchresponse._shards.md) | ShardsResponse | | | [aggregations](./kibana-plugin-core-server.searchresponse.aggregations.md) | any | | -| [hits](./kibana-plugin-core-server.searchresponse.hits.md) | {
total: number;
max_score: number;
hits: Array<{
_index: string;
_type: string;
_id: string;
_score: number;
_source: T;
_version?: number;
_explanation?: Explanation;
fields?: any;
highlight?: any;
inner_hits?: any;
matched_queries?: string[];
sort?: string[];
}>;
} | | +| [hits](./kibana-plugin-core-server.searchresponse.hits.md) | {
total: number;
max_score: number;
hits: Array<{
_index: string;
_type: string;
_id: string;
_score: number;
_source: T;
_version?: number;
_explanation?: Explanation;
fields?: any;
highlight?: any;
inner_hits?: any;
matched_queries?: string[];
sort?: unknown[];
}>;
} | | +| [pit\_id](./kibana-plugin-core-server.searchresponse.pit_id.md) | string | | | [timed\_out](./kibana-plugin-core-server.searchresponse.timed_out.md) | boolean | | | [took](./kibana-plugin-core-server.searchresponse.took.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md b/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md new file mode 100644 index 00000000000000..f214bc05380455 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.searchresponse.pit_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SearchResponse](./kibana-plugin-core-server.searchresponse.md) > [pit\_id](./kibana-plugin-core-server.searchresponse.pit_id.md) + +## SearchResponse.pit\_id property + +Signature: + +```typescript +pit_id?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md index d35afc4a149d19..4bb7be77c595a9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md @@ -23,6 +23,7 @@ export interface UiSettingsParams | [name](./kibana-plugin-core-server.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-server.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-server.uisettingsparams.options.md) | string[] | array of permitted values for this setting | +| [order](./kibana-plugin-core-server.uisettingsparams.order.md) | number | index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. settings without order defined will be displayed last and ordered by name | | [readonly](./kibana-plugin-core-server.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-core-server.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [schema](./kibana-plugin-core-server.uisettingsparams.schema.md) | Type<T> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md new file mode 100644 index 00000000000000..140bdad5d786bf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.order.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) > [order](./kibana-plugin-core-server.uisettingsparams.order.md) + +## UiSettingsParams.order property + +index of the settings within its category (ascending order, smallest will be displayed first). Used for ordering in the UI. + + settings without order defined will be displayed last and ordered by name + +Signature: + +```typescript +order?: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md b/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md index 3c439897ea0310..7edee442baa24b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingstype.md @@ -9,5 +9,5 @@ UI element type to represent the settings. Signature: ```typescript -export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export declare type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index f576d795b93a52..d2e7ef9db05e8e 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -126,6 +126,7 @@ | [noSearchSessionStorageCapabilityMessage](./kibana-plugin-plugins-data-public.nosearchsessionstoragecapabilitymessage.md) | Message to display in case storing session session is disabled due to turned off capability | | [parseSearchSourceJSON](./kibana-plugin-plugins-data-public.parsesearchsourcejson.md) | | | [QueryStringInput](./kibana-plugin-plugins-data-public.querystringinput.md) | | +| [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md) | | | [search](./kibana-plugin-plugins-data-public.search.md) | | | [SearchBar](./kibana-plugin-plugins-data-public.searchbar.md) | | | [syncQueryStateWithUrl](./kibana-plugin-plugins-data-public.syncquerystatewithurl.md) | Helper to setup syncing of global data with the URL | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md new file mode 100644 index 00000000000000..ad16d21403a984 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search_sessions_management_id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SEARCH\_SESSIONS\_MANAGEMENT\_ID](./kibana-plugin-plugins-data-public.search_sessions_management_id.md) + +## SEARCH\_SESSIONS\_MANAGEMENT\_ID variable + +Signature: + +```typescript +SEARCH_SESSIONS_MANAGEMENT_ID = "search_sessions" +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md index 034f9c70e389fe..d5a8ec311df31e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md @@ -9,7 +9,7 @@ Clears the [editor state](./kibana-plugin-plugins-embeddable-public.embeddableed Signature: ```typescript -clearEditorState(appId: string): void; +clearEditorState(appId?: string): void; ``` ## Parameters diff --git a/docs/user/alerting/alerting-production-considerations.asciidoc b/docs/user/alerting/alerting-production-considerations.asciidoc index 3a68e81879e249..cc7adc87b150ef 100644 --- a/docs/user/alerting/alerting-production-considerations.asciidoc +++ b/docs/user/alerting/alerting-production-considerations.asciidoc @@ -27,4 +27,9 @@ Because by default tasks are polled at 3 second intervals and only 10 tasks can For details on the settings that can influence the performance and throughput of Task Manager, see {task-manager-settings}. -============================================== \ No newline at end of file +============================================== + +[float] +=== Deployment considerations + +{es} and {kib} instances use the system clock to determine the current time. To ensure schedules are triggered when expected, you should synchronize the clocks of all nodes in the cluster using a time service such as http://www.ntp.org/[Network Time Protocol]. \ No newline at end of file diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 88fd870fefa74d..cc384ec041a9da 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -401,7 +401,9 @@ Vega-Lite compilation. [[vega-expression-functions]] ===== (Vega only) Expression functions which can update the time range and dashboard filters -{kib} has extended the Vega expression language with these functions: +{kib} has extended the Vega expression language with these functions. +These functions will trigger new data to be fetched, which by default will reset Vega signals. +To keep signal values set `restoreSignalValuesOnRefresh: true` in the Vega config. ```js /** @@ -444,6 +446,8 @@ kibanaSetTimeFilter(start, end) hideWarnings: true // Vega renderer to use: `svg` or `canvas` (default) renderer: canvas + // Defaults to 'false', restores Vega signal values on refresh + restoreSignalValuesOnRefresh: false } } } diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 12a87b1422c5cd..b9fc0c9c4ac466 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -85,6 +85,10 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is creating a saved object. | `failure` | User is not authorized to create a saved object. +.2+| `saved_object_open_point_in_time` +| `unknown` | User is creating a Point In Time to use when querying saved objects. +| `failure` | User is not authorized to create a Point In Time for the provided saved object types. + .2+| `connector_create` | `unknown` | User is creating a connector. | `failure` | User is not authorized to create a connector. @@ -171,6 +175,10 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is deleting a saved object. | `failure` | User is not authorized to delete a saved object. +.2+| `saved_object_close_point_in_time` +| `unknown` | User is deleting a Point In Time that was used to query saved objects. +| `failure` | User is not authorized to delete a Point In Time. + .2+| `connector_delete` | `unknown` | User is deleting a connector. | `failure` | User is not authorized to delete a connector. diff --git a/package.json b/package.json index 0fa8ef31ab251d..ed21cb7052c1cb 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "@kbn/ace": "link:packages/kbn-ace", "@kbn/analytics": "link:packages/kbn-analytics", "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", + "@kbn/apm-utils": "link:packages/kbn-apm-utils", "@kbn/config": "link:packages/kbn-config", "@kbn/config-schema": "link:packages/kbn-config-schema", "@kbn/i18n": "link:packages/kbn-i18n", @@ -129,6 +130,7 @@ "@kbn/logging": "link:packages/kbn-logging", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/std": "link:packages/kbn-std", + "@kbn/tinymath": "link:packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", "@kbn/utils": "link:packages/kbn-utils", @@ -312,7 +314,6 @@ "tabbable": "1.1.3", "tar": "4.4.13", "tinygradient": "0.4.3", - "@kbn/tinymath": "link:packages/kbn-tinymath", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", @@ -351,7 +352,7 @@ "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "24.4.0", + "@elastic/charts": "24.5.1", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", @@ -390,10 +391,10 @@ "@scant/router": "^0.1.0", "@storybook/addon-a11y": "^6.0.26", "@storybook/addon-actions": "^6.0.26", + "@storybook/addon-docs": "^6.0.26", "@storybook/addon-essentials": "^6.0.26", "@storybook/addon-knobs": "^6.0.26", "@storybook/addon-storyshots": "^6.0.26", - "@storybook/addon-docs": "^6.0.26", "@storybook/components": "^6.0.26", "@storybook/core": "^6.0.26", "@storybook/core-events": "^6.0.26", @@ -851,4 +852,4 @@ "yargs": "^15.4.1", "zlib": "^1.0.5" } -} \ No newline at end of file +} diff --git a/packages/kbn-apm-utils/package.json b/packages/kbn-apm-utils/package.json new file mode 100644 index 00000000000000..d414b94cb39789 --- /dev/null +++ b/packages/kbn-apm-utils/package.json @@ -0,0 +1,13 @@ +{ + "name": "@kbn/apm-utils", + "main": "./target/index.js", + "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" + } +} diff --git a/packages/kbn-apm-utils/src/index.ts b/packages/kbn-apm-utils/src/index.ts new file mode 100644 index 00000000000000..f2f537138dad07 --- /dev/null +++ b/packages/kbn-apm-utils/src/index.ts @@ -0,0 +1,87 @@ +/* + * 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 agent from 'elastic-apm-node'; +import asyncHooks from 'async_hooks'; + +export interface SpanOptions { + name: string; + type?: string; + subtype?: string; + labels?: Record; +} + +export function parseSpanOptions(optionsOrName: SpanOptions | string) { + const options = typeof optionsOrName === 'string' ? { name: optionsOrName } : optionsOrName; + + return options; +} + +const runInNewContext = any>(cb: T): ReturnType => { + const resource = new asyncHooks.AsyncResource('fake_async'); + + return resource.runInAsyncScope(cb); +}; + +export async function withSpan( + optionsOrName: SpanOptions | string, + cb: () => Promise +): Promise { + const options = parseSpanOptions(optionsOrName); + + const { name, type, subtype, labels } = options; + + if (!agent.isStarted()) { + return cb(); + } + + // When a span starts, it's marked as the active span in its context. + // When it ends, it's not untracked, which means that if a span + // starts directly after this one ends, the newly started span is a + // child of this span, even though it should be a sibling. + // To mitigate this, we queue a microtask by awaiting a promise. + await Promise.resolve(); + + const span = agent.startSpan(name); + + if (!span) { + return cb(); + } + + // If a span is created in the same context as the span that we just + // started, it will be a sibling, not a child. E.g., the Elasticsearch span + // that is created when calling search() happens in the same context. To + // mitigate this we create a new context. + + return runInNewContext(() => { + // @ts-ignore + if (type) { + span.type = type; + } + if (subtype) { + span.subtype = subtype; + } + + if (labels) { + span.addLabels(labels); + } + + return cb() + .then((res) => { + span.outcome = 'success'; + return res; + }) + .catch((err) => { + span.outcome = 'failure'; + throw err; + }) + .finally(() => { + span.end(); + }); + }); +} diff --git a/packages/kbn-apm-utils/tsconfig.json b/packages/kbn-apm-utils/tsconfig.json new file mode 100644 index 00000000000000..e1f79b5ef394da --- /dev/null +++ b/packages/kbn-apm-utils/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./target", + "stripInternal": false, + "declarationMap": true, + "types": [ + "node" + ] + }, + "include": [ + "./src/**/*.ts" + ], + "exclude": [ + "target" + ] +} diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index a1e40c06f6fa17..6d81b39df71135 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -91,7 +91,7 @@ pageLoadAssetSize: visTypeMetric: 42790 visTypeTable: 94934 visTypeTagcloud: 37575 - visTypeTimelion: 51933 + visTypeTimelion: 68883 visTypeTimeseries: 155203 visTypeVega: 153573 visTypeVislib: 242838 @@ -105,3 +105,4 @@ pageLoadAssetSize: spacesOss: 18817 osquery: 107090 fileUpload: 25664 + banners: 17946 diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index 79fc3db86e066b..a1475985af8df0 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -37,7 +37,6 @@ module.exports = { '\\.ace\\.worker.js$': '/packages/kbn-test/target/jest/mocks/worker_module_mock.js', '\\.editor\\.worker.js$': '/packages/kbn-test/target/jest/mocks/worker_module_mock.js', '^(!!)?file-loader!': '/packages/kbn-test/target/jest/mocks/file_mock.js', - '^fixtures/(.*)': '/src/fixtures/$1', '^src/core/(.*)': '/src/core/$1', '^src/plugins/(.*)': '/src/plugins/$1', }, diff --git a/packages/kbn-utils/src/package_json/index.test.ts b/packages/kbn-utils/src/package_json/index.test.ts index 49aace6b4ff93e..f6d7e1f2f611b6 100644 --- a/packages/kbn-utils/src/package_json/index.test.ts +++ b/packages/kbn-utils/src/package_json/index.test.ts @@ -7,14 +7,14 @@ */ import path from 'path'; -import { kibanaPackageJSON } from './'; +import { kibanaPackageJson } from './'; it('parses package.json', () => { - expect(kibanaPackageJSON.name).toEqual('kibana'); + expect(kibanaPackageJson.name).toEqual('kibana'); }); it('includes __dirname and __filename', () => { const root = path.resolve(__dirname, '../../../../'); - expect(kibanaPackageJSON.__filename).toEqual(path.resolve(root, 'package.json')); - expect(kibanaPackageJSON.__dirname).toEqual(root); + expect(kibanaPackageJson.__filename).toEqual(path.resolve(root, 'package.json')); + expect(kibanaPackageJson.__dirname).toEqual(root); }); diff --git a/packages/kbn-utils/src/package_json/index.ts b/packages/kbn-utils/src/package_json/index.ts index 0368d883896e92..40ce353780749a 100644 --- a/packages/kbn-utils/src/package_json/index.ts +++ b/packages/kbn-utils/src/package_json/index.ts @@ -9,7 +9,7 @@ import { dirname, resolve } from 'path'; import { REPO_ROOT } from '../repo_root'; -export const kibanaPackageJSON = { +export const kibanaPackageJson = { __filename: resolve(REPO_ROOT, 'package.json'), __dirname: dirname(resolve(REPO_ROOT, 'package.json')), ...require(resolve(REPO_ROOT, 'package.json')), diff --git a/src/core/public/_mixins.scss b/src/core/public/_mixins.scss new file mode 100644 index 00000000000000..2dbef465e074e6 --- /dev/null +++ b/src/core/public/_mixins.scss @@ -0,0 +1,43 @@ +@import './variables'; + +/* stylelint-disable-next-line length-zero-no-unit -- need consistent unit to sum them */ +@mixin kibanaFullBodyHeight($additionalOffset: 0px) { + // default - header, no banner + height: calc(100vh - #{$kbnHeaderOffset + $additionalOffset}); + + @at-root { + // no header, no banner + .kbnBody--chromeHidden & { + height: calc(100vh - #{$additionalOffset}); + } + // header, banner + .kbnBody--hasHeaderBanner & { + height: calc(100vh - #{$kbnHeaderOffsetWithBanner + $additionalOffset}); + } + // no header, banner + .kbnBody--chromeHidden.kbnBody--hasHeaderBanner & { + height: calc(100vh - #{$kbnHeaderBannerHeight + $additionalOffset}); + } + } +} + +/* stylelint-disable-next-line length-zero-no-unit -- need consistent unit to sum them */ +@mixin kibanaFullBodyMinHeight($additionalOffset: 0px) { + // default - header, no banner + min-height: calc(100vh - #{$kbnHeaderOffset + $additionalOffset}); + + @at-root { + // no header, no banner + .kbnBody--chromeHidden & { + min-height: calc(100vh - #{$additionalOffset}); + } + // header, banner + .kbnBody--hasHeaderBanner & { + min-height: calc(100vh - #{$kbnHeaderOffsetWithBanner + $additionalOffset}); + } + // no header, banner + .kbnBody--chromeHidden.kbnBody--hasHeaderBanner & { + min-height: calc(100vh - #{$kbnHeaderBannerHeight + $additionalOffset}); + } + } +} diff --git a/src/core/public/_variables.scss b/src/core/public/_variables.scss index 8c054e770bd4b7..f6ff5619bfc534 100644 --- a/src/core/public/_variables.scss +++ b/src/core/public/_variables.scss @@ -1,3 +1,8 @@ @import '@elastic/eui/src/global_styling/variables/header'; +// height of the header banner +$kbnHeaderBannerHeight: $euiSizeXL; +// total height of the header (when the banner is *not* present) $kbnHeaderOffset: $euiHeaderHeightCompensation * 2; +// total height of the header when the banner is present +$kbnHeaderOffsetWithBanner: $kbnHeaderOffset + $kbnHeaderBannerHeight; diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index cb0876f6bc7253..ae9c58af696032 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -61,6 +61,8 @@ const createStartContractMock = () => { getIsNavDrawerLocked$: jest.fn(), getCustomNavLink$: jest.fn(), setCustomNavLink: jest.fn(), + setHeaderBanner: jest.fn(), + getBodyClasses$: jest.fn(), }; startContract.navLinks.getAll.mockReturnValue([]); startContract.getBrand$.mockReturnValue(new BehaviorSubject({} as ChromeBrand)); @@ -72,6 +74,7 @@ const createStartContractMock = () => { startContract.getCustomNavLink$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getHelpExtension$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getIsNavDrawerLocked$.mockReturnValue(new BehaviorSubject(false)); + startContract.getBodyClasses$.mockReturnValue(new BehaviorSubject([])); return startContract; }; diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index ee8d1c17ccd59c..e69bf9025fdb92 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -6,69 +6,37 @@ * Side Public License, v 1. */ -import { EuiBreadcrumb, IconType } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { parse } from 'url'; import { EuiLink } from '@elastic/eui'; -import { MountPoint } from '../types'; import { mountReactNode } from '../utils/mount'; import { InternalApplicationStart } from '../application'; import { DocLinksStart } from '../doc_links'; import { HttpStart } from '../http'; import { InjectedMetadataStart } from '../injected_metadata'; import { NotificationsStart } from '../notifications'; -import { IUiSettingsClient } from '../ui_settings'; import { KIBANA_ASK_ELASTIC_LINK } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; -import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; +import { NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; -import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; +import { + ChromeBadge, + ChromeBrand, + ChromeBreadcrumb, + ChromeBreadcrumbsAppendExtension, + ChromeHelpExtension, + InternalChromeStart, + ChromeUserBanner, +} from './types'; const IS_LOCKED_KEY = 'core.chrome.isLocked'; -/** @public */ -export interface ChromeBadge { - text: string; - tooltip: string; - iconType?: IconType; -} - -/** @public */ -export interface ChromeBrand { - logo?: string; - smallLogo?: string; -} - -/** @public */ -export type ChromeBreadcrumb = EuiBreadcrumb; - -/** @public */ -export interface ChromeBreadcrumbsAppendExtension { - content: MountPoint; -} - -/** @public */ -export interface ChromeHelpExtension { - /** - * Provide your plugin's name to create a header for separation - */ - appName: string; - /** - * Creates unified links for sending users to documentation, GitHub, Discuss, or a custom link/button - */ - links?: ChromeHelpExtensionMenuLink[]; - /** - * Custom content to occur below the list of links - */ - content?: (element: HTMLDivElement) => () => void; -} - interface ConstructorParams { browserSupportsCsp: boolean; } @@ -79,7 +47,6 @@ interface StartDeps { http: HttpStart; injectedMetadata: InjectedMetadataStart; notifications: NotificationsStart; - uiSettings: IUiSettingsClient; } /** @internal */ @@ -132,7 +99,6 @@ export class ChromeService { http, injectedMetadata, notifications, - uiSettings, }: StartDeps): Promise { this.initVisibility(application); @@ -149,6 +115,17 @@ export class ChromeService { const helpSupportUrl$ = new BehaviorSubject(KIBANA_ASK_ELASTIC_LINK); const isNavDrawerLocked$ = new BehaviorSubject(localStorage.getItem(IS_LOCKED_KEY) === 'true'); + const headerBanner$ = new BehaviorSubject(undefined); + const bodyClasses$ = combineLatest([headerBanner$, this.isVisible$!]).pipe( + map(([headerBanner, isVisible]) => { + return [ + 'kbnBody', + headerBanner ? 'kbnBody--hasHeaderBanner' : 'kbnBody--noHeaderBanner', + isVisible ? 'kbnBody--chromeVisible' : 'kbnBody--chromeHidden', + ]; + }) + ); + const navControls = this.navControls.start(); const navLinks = this.navLinks.start({ application, http }); const recentlyAccessed = await this.recentlyAccessed.start({ http }); @@ -220,6 +197,7 @@ export class ChromeService { loadingCount$={http.getLoadingCount$()} application={application} appTitle$={appTitle$.pipe(takeUntil(this.stop$))} + headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))} badge$={badge$.pipe(takeUntil(this.stop$))} basePath={http.basePath} breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))} @@ -312,6 +290,12 @@ export class ChromeService { setCustomNavLink: (customNavLink?: ChromeNavLink) => { customNavLink$.next(customNavLink); }, + + setHeaderBanner: (headerBanner?: ChromeUserBanner) => { + headerBanner$.next(headerBanner); + }, + + getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)), }; } @@ -320,173 +304,3 @@ export class ChromeService { this.stop$.next(); } } - -/** - * ChromeStart allows plugins to customize the global chrome header UI and - * enrich the UX with additional information about the current location of the - * browser. - * - * @remarks - * While ChromeStart exposes many APIs, they should be used sparingly and the - * developer should understand how they affect other plugins and applications. - * - * @example - * How to add a recently accessed item to the sidebar: - * ```ts - * core.chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234'); - * ``` - * - * @example - * How to set the help dropdown extension: - * ```tsx - * core.chrome.setHelpExtension(elem => { - * ReactDOM.render(, elem); - * return () => ReactDOM.unmountComponentAtNode(elem); - * }); - * ``` - * - * @public - */ -export interface ChromeStart { - /** {@inheritdoc ChromeNavLinks} */ - navLinks: ChromeNavLinks; - /** {@inheritdoc ChromeNavControls} */ - navControls: ChromeNavControls; - /** {@inheritdoc ChromeRecentlyAccessed} */ - recentlyAccessed: ChromeRecentlyAccessed; - /** {@inheritdoc ChromeDocTitle} */ - docTitle: ChromeDocTitle; - - /** - * Sets the current app's title - * - * @internalRemarks - * This should be handled by the application service once it is in charge - * of mounting applications. - */ - setAppTitle(appTitle: string): void; - - /** - * Get an observable of the current brand information. - */ - getBrand$(): Observable; - - /** - * Set the brand configuration. - * - * @remarks - * Normally the `logo` property will be rendered as the - * CSS background for the home link in the chrome navigation, but when the page is - * rendered in a small window the `smallLogo` will be used and rendered at about - * 45px wide. - * - * @example - * ```js - * chrome.setBrand({ - * logo: 'url(/plugins/app/logo.png) center no-repeat' - * smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' - * }) - * ``` - * - */ - setBrand(brand: ChromeBrand): void; - - /** - * Get an observable of the current visibility state of the chrome. - */ - getIsVisible$(): Observable; - - /** - * Set the temporary visibility for the chrome. This does nothing if the chrome is hidden - * by default and should be used to hide the chrome for things like full-screen modes - * with an exit button. - */ - setIsVisible(isVisible: boolean): void; - - /** - * Get the current set of classNames that will be set on the application container. - */ - getApplicationClasses$(): Observable; - - /** - * Add a className that should be set on the application container. - */ - addApplicationClass(className: string): void; - - /** - * Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. - */ - removeApplicationClass(className: string): void; - - /** - * Get an observable of the current badge - */ - getBadge$(): Observable; - - /** - * Override the current badge - */ - setBadge(badge?: ChromeBadge): void; - - /** - * Get an observable of the current list of breadcrumbs - */ - getBreadcrumbs$(): Observable; - - /** - * Override the current set of breadcrumbs - */ - setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; - - /** - * Get an observable of the current extension appended to breadcrumbs - */ - getBreadcrumbsAppendExtension$(): Observable; - - /** - * Mount an element next to the last breadcrumb - */ - setBreadcrumbsAppendExtension( - breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension - ): void; - - /** - * Get an observable of the current custom nav link - */ - getCustomNavLink$(): Observable | undefined>; - - /** - * Override the current set of custom nav link - */ - setCustomNavLink(newCustomNavLink?: Partial): void; - - /** - * Get an observable of the current custom help conttent - */ - getHelpExtension$(): Observable; - - /** - * Override the current set of custom help content - */ - setHelpExtension(helpExtension?: ChromeHelpExtension): void; - - /** - * Override the default support URL shown in the help menu - * @param url The updated support URL - */ - setHelpSupportUrl(url: string): void; - - /** - * Get an observable of the current locked state of the nav drawer. - */ - getIsNavDrawerLocked$(): Observable; -} - -/** @internal */ -export interface InternalChromeStart extends ChromeStart { - /** - * Used only by MountingService to render the header UI - * @internal - */ - getHeaderComponent(): JSX.Element; -} diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index cf4106a42e0d4d..069d29ca70d01e 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -6,15 +6,7 @@ * Side Public License, v 1. */ -export { - ChromeBadge, - ChromeBreadcrumb, - ChromeService, - ChromeStart, - InternalChromeStart, - ChromeBrand, - ChromeHelpExtension, -} from './chrome_service'; +export { ChromeService } from './chrome_service'; export { ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, @@ -28,3 +20,13 @@ export { ChromeNavLink, ChromeNavLinks, ChromeNavLinkUpdateableFields } from './ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem } from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; +export { + InternalChromeStart, + ChromeStart, + ChromeHelpExtension, + ChromeBreadcrumbsAppendExtension, + ChromeBreadcrumb, + ChromeBrand, + ChromeBadge, + ChromeUserBanner, +} from './types'; diff --git a/src/core/public/chrome/types.ts b/src/core/public/chrome/types.ts new file mode 100644 index 00000000000000..732236f1ba4a12 --- /dev/null +++ b/src/core/public/chrome/types.ts @@ -0,0 +1,241 @@ +/* + * 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 { EuiBreadcrumb, IconType } from '@elastic/eui'; +import { Observable } from 'rxjs'; +import { MountPoint } from '../types'; +import { ChromeDocTitle } from './doc_title'; +import { ChromeNavControls } from './nav_controls'; +import { ChromeNavLinks, ChromeNavLink } from './nav_links'; +import { ChromeRecentlyAccessed } from './recently_accessed'; +import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; + +/** @public */ +export interface ChromeBadge { + text: string; + tooltip: string; + iconType?: IconType; +} + +/** @public */ +export interface ChromeBrand { + logo?: string; + smallLogo?: string; +} + +/** @public */ +export type ChromeBreadcrumb = EuiBreadcrumb; + +/** @public */ +export interface ChromeBreadcrumbsAppendExtension { + content: MountPoint; +} + +/** @public */ +export interface ChromeUserBanner { + content: MountPoint; +} + +/** @public */ +export interface ChromeHelpExtension { + /** + * Provide your plugin's name to create a header for separation + */ + appName: string; + /** + * Creates unified links for sending users to documentation, GitHub, Discuss, or a custom link/button + */ + links?: ChromeHelpExtensionMenuLink[]; + /** + * Custom content to occur below the list of links + */ + content?: (element: HTMLDivElement) => () => void; +} + +/** + * ChromeStart allows plugins to customize the global chrome header UI and + * enrich the UX with additional information about the current location of the + * browser. + * + * @remarks + * While ChromeStart exposes many APIs, they should be used sparingly and the + * developer should understand how they affect other plugins and applications. + * + * @example + * How to add a recently accessed item to the sidebar: + * ```ts + * core.chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234'); + * ``` + * + * @example + * How to set the help dropdown extension: + * ```tsx + * core.chrome.setHelpExtension(elem => { + * ReactDOM.render(, elem); + * return () => ReactDOM.unmountComponentAtNode(elem); + * }); + * ``` + * + * @public + */ +export interface ChromeStart { + /** {@inheritdoc ChromeNavLinks} */ + navLinks: ChromeNavLinks; + /** {@inheritdoc ChromeNavControls} */ + navControls: ChromeNavControls; + /** {@inheritdoc ChromeRecentlyAccessed} */ + recentlyAccessed: ChromeRecentlyAccessed; + /** {@inheritdoc ChromeDocTitle} */ + docTitle: ChromeDocTitle; + + /** + * Sets the current app's title + * + * @internalRemarks + * This should be handled by the application service once it is in charge + * of mounting applications. + */ + setAppTitle(appTitle: string): void; + + /** + * Get an observable of the current brand information. + */ + getBrand$(): Observable; + + /** + * Set the brand configuration. + * + * @remarks + * Normally the `logo` property will be rendered as the + * CSS background for the home link in the chrome navigation, but when the page is + * rendered in a small window the `smallLogo` will be used and rendered at about + * 45px wide. + * + * @example + * ```js + * chrome.setBrand({ + * logo: 'url(/plugins/app/logo.png) center no-repeat' + * smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' + * }) + * ``` + * + */ + setBrand(brand: ChromeBrand): void; + + /** + * Get an observable of the current visibility state of the chrome. + */ + getIsVisible$(): Observable; + + /** + * Set the temporary visibility for the chrome. This does nothing if the chrome is hidden + * by default and should be used to hide the chrome for things like full-screen modes + * with an exit button. + */ + setIsVisible(isVisible: boolean): void; + + /** + * Get the current set of classNames that will be set on the application container. + */ + getApplicationClasses$(): Observable; + + /** + * Add a className that should be set on the application container. + */ + addApplicationClass(className: string): void; + + /** + * Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. + */ + removeApplicationClass(className: string): void; + + /** + * Get an observable of the current badge + */ + getBadge$(): Observable; + + /** + * Override the current badge + */ + setBadge(badge?: ChromeBadge): void; + + /** + * Get an observable of the current list of breadcrumbs + */ + getBreadcrumbs$(): Observable; + + /** + * Override the current set of breadcrumbs + */ + setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; + + /** + * Get an observable of the current extension appended to breadcrumbs + */ + getBreadcrumbsAppendExtension$(): Observable; + + /** + * Mount an element next to the last breadcrumb + */ + setBreadcrumbsAppendExtension( + breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension + ): void; + + /** + * Get an observable of the current custom nav link + */ + getCustomNavLink$(): Observable | undefined>; + + /** + * Override the current set of custom nav link + */ + setCustomNavLink(newCustomNavLink?: Partial): void; + + /** + * Get an observable of the current custom help conttent + */ + getHelpExtension$(): Observable; + + /** + * Override the current set of custom help content + */ + setHelpExtension(helpExtension?: ChromeHelpExtension): void; + + /** + * Override the default support URL shown in the help menu + * @param url The updated support URL + */ + setHelpSupportUrl(url: string): void; + + /** + * Get an observable of the current locked state of the nav drawer. + */ + getIsNavDrawerLocked$(): Observable; + + /** + * Set the banner that will appear on top of the chrome header. + * + * @remarks Using `undefined` when invoking this API will remove the banner. + */ + setHeaderBanner(headerBanner?: ChromeUserBanner): void; +} + +/** @internal */ +export interface InternalChromeStart extends ChromeStart { + /** + * Used only by the rendering service to render the header UI + * @internal + */ + getHeaderComponent(): JSX.Element; + /** + * Used only by the rendering service to retrieve the set of classNames + * that will be set on the body element. + * @internal + */ + getBodyClasses$(): Observable; +} diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 9e7906250949e4..4f31c952b88268 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -452,6 +452,55 @@ exports[`Header renders 1`] = ` "thrownError": null, } } + headerBanner$={ + BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } helpExtension$={ BehaviorSubject { "_isScalar": false, @@ -1699,14 +1748,67 @@ exports[`Header renders 1`] = ` } } > +
({ htmlIdGenerator: () => () => 'mockId', @@ -63,6 +63,7 @@ describe('Header', () => { const navLinks$ = new BehaviorSubject([ { id: 'kibana', title: 'kibana', baseUrl: '', href: '' }, ]); + const headerBanner$ = new BehaviorSubject(undefined); const customNavLink$ = new BehaviorSubject({ id: 'cloud-deployment-link', title: 'Manage cloud deployment', @@ -85,6 +86,7 @@ describe('Header', () => { isLocked$={isLocked$} customNavLink$={customNavLink$} breadcrumbsAppendExtension$={breadcrumbsAppendExtension$} + headerBanner$={headerBanner$} /> ); expect(component.find('EuiHeader').exists()).toBeFalsy(); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index b55e7fc412b61a..16c89fdca380ab 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -32,7 +32,11 @@ import { } from '../..'; import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; -import { ChromeBreadcrumbsAppendExtension, ChromeHelpExtension } from '../../chrome_service'; +import { + ChromeBreadcrumbsAppendExtension, + ChromeHelpExtension, + ChromeUserBanner, +} from '../../types'; import { OnIsLockedUpdate } from './'; import { CollapsibleNav } from './collapsible_nav'; import { HeaderBadge } from './header_badge'; @@ -42,10 +46,12 @@ import { HeaderLogo } from './header_logo'; import { HeaderNavControls } from './header_nav_controls'; import { HeaderActionMenu } from './header_action_menu'; import { HeaderExtension } from './header_extension'; +import { HeaderTopBanner } from './header_top_banner'; export interface HeaderProps { kibanaVersion: string; application: InternalApplicationStart; + headerBanner$: Observable; appTitle$: Observable; badge$: Observable; breadcrumbs$: Observable; @@ -84,7 +90,12 @@ export function Header({ const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$); if (!isVisible) { - return ; + return ( + <> + + + + ); } const toggleCollapsibleNavRef = createRef(); @@ -97,11 +108,13 @@ export function Header({ return ( <> +
-
+
- + ; diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index 4db79d0233ae9a..0e2bae82a3ad30 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -11,7 +11,7 @@ import classNames from 'classnames'; import React from 'react'; import useObservable from 'react-use/lib/useObservable'; import { Observable } from 'rxjs'; -import { ChromeBreadcrumb } from '../../chrome_service'; +import { ChromeBreadcrumb } from '../../types'; interface Props { appTitle$: Observable; diff --git a/src/core/public/chrome/ui/header/header_help_menu.tsx b/src/core/public/chrome/ui/header/header_help_menu.tsx index a20613f7e77efb..c6a09c1177a5e8 100644 --- a/src/core/public/chrome/ui/header/header_help_menu.tsx +++ b/src/core/public/chrome/ui/header/header_help_menu.tsx @@ -26,7 +26,7 @@ import { import { InternalApplicationStart } from '../../../application'; import { GITHUB_CREATE_ISSUE_LINK, KIBANA_FEEDBACK_LINK } from '../../constants'; -import { ChromeHelpExtension } from '../../chrome_service'; +import { ChromeHelpExtension } from '../../types'; import { HeaderExtension } from './header_extension'; import { isModifiedOrPrevented } from './nav_link'; diff --git a/src/core/public/chrome/ui/header/header_top_banner.tsx b/src/core/public/chrome/ui/header/header_top_banner.tsx new file mode 100644 index 00000000000000..667cf9025880f3 --- /dev/null +++ b/src/core/public/chrome/ui/header/header_top_banner.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 React, { FC } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { ChromeUserBanner } from '../../types'; +import { HeaderExtension } from './header_extension'; + +export interface HeaderTopBannerProps { + headerBanner$: Observable; +} + +export const HeaderTopBanner: FC = ({ headerBanner$ }) => { + const headerBanner = useObservable(headerBanner$, undefined); + if (!headerBanner) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/src/core/public/core_app/styles/_mixins.scss b/src/core/public/core_app/styles/_mixins.scss index 6da7fab8c2f76d..d088a47144f33a 100644 --- a/src/core/public/core_app/styles/_mixins.scss +++ b/src/core/public/core_app/styles/_mixins.scss @@ -1,3 +1,5 @@ +@import '../../variables'; + @mixin flexParent($grow: 1, $shrink: 1, $basis: auto, $direction: column) { flex: $grow $shrink $basis; display: flex; @@ -82,6 +84,12 @@ overflow: auto; animation: kibanaFullScreenGraphics_FadeIn $euiAnimSpeedExtraSlow $euiAnimSlightResistance 0s forwards; + @at-root { + .kbnBody--hasHeaderBanner & { + top: $kbnHeaderBannerHeight; + } + } + &::before { position: absolute; top: 0; diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 02e12ddf4b78bf..278bbe469e862d 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -194,7 +194,6 @@ export class CoreSystem { http, injectedMetadata, notifications, - uiSettings, }); this.coreApp.start({ application, http, notifications, uiSettings }); diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index da35373f57322a..0d40899544c085 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -110,7 +110,9 @@ export class DocLinksService { scriptAggs: `${ELASTICSEARCH_DOCS}search-aggregations.html#_values_source`, painless: `${ELASTICSEARCH_DOCS}modules-scripting-painless.html`, painlessApi: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, + painlessLangSpec: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, painlessSyntax: `${ELASTICSEARCH_DOCS}modules-scripting-painless-syntax.html`, + painlessWalkthrough: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-walkthrough.html`, painlessLanguage: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, luceneExpressions: `${ELASTICSEARCH_DOCS}modules-scripting-expression.html`, }, @@ -239,6 +241,7 @@ export class DocLinksService { openIndex: `${ELASTICSEARCH_DOCS}indices-open-close.html`, putComponentTemplate: `${ELASTICSEARCH_DOCS}indices-component-template.html`, painlessExecute: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html`, + painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, putComponentTemplateMetadata: `${ELASTICSEARCH_DOCS}indices-component-template.html#component-templates-metadata`, putWatch: `${ELASTICSEARCH_DOCS}/watcher-api-put-watch.html`, updateTransform: `${ELASTICSEARCH_DOCS}update-transform.html`, @@ -336,7 +339,9 @@ export interface DocLinksStart { readonly scriptAggs: string; readonly painless: string; readonly painlessApi: string; + readonly painlessLangSpec: string; readonly painlessSyntax: string; + readonly painlessWalkthrough: string; readonly luceneExpressions: string; }; readonly indexPatterns: { @@ -376,6 +381,7 @@ export interface DocLinksStart { openIndex: string; putComponentTemplate: string; painlessExecute: string; + painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putWatch: string; updateTransform: string; diff --git a/src/core/public/index.scss b/src/core/public/index.scss index 6ba9254e5d381f..04e2759c91d5dc 100644 --- a/src/core/public/index.scss +++ b/src/core/public/index.scss @@ -1,4 +1,5 @@ @import './variables'; +@import './mixins'; @import './core'; @import './chrome/index'; @import './overlays/index'; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index a1cb036ce38f8f..67a7f15fd92411 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -46,6 +46,7 @@ import { ChromeStart, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + ChromeUserBanner, NavType, } from './chrome'; import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors'; @@ -299,6 +300,7 @@ export { ChromeDocTitle, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + ChromeUserBanner, ChromeStart, DocLinksStart, FatalErrorInfo, diff --git a/src/core/public/overlays/modal/modal_service.tsx b/src/core/public/overlays/modal/modal_service.tsx index ecc80b8b6aa040..1f96e00fef0f89 100644 --- a/src/core/public/overlays/modal/modal_service.tsx +++ b/src/core/public/overlays/modal/modal_service.tsx @@ -101,6 +101,7 @@ export interface OverlayModalOpenOptions { className?: string; closeButtonAriaLabel?: string; 'data-test-subj'?: string; + maxWidth?: boolean | number | string; } interface StartDeps { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 99579ada8ec588..2e23b26f636c8f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -378,11 +378,18 @@ export interface ChromeStart { setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; setBreadcrumbsAppendExtension(breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension): void; setCustomNavLink(newCustomNavLink?: Partial): void; + setHeaderBanner(headerBanner?: ChromeUserBanner): void; setHelpExtension(helpExtension?: ChromeHelpExtension): void; setHelpSupportUrl(url: string): void; setIsVisible(isVisible: boolean): void; } +// @public (undocumented) +export interface ChromeUserBanner { + // (undocumented) + content: MountPoint; +} + // @internal (undocumented) export interface CoreContext { // Warning: (ae-forgotten-export) The symbol "CoreId" needs to be exported by the entry point index.d.ts @@ -554,7 +561,9 @@ export interface DocLinksStart { readonly scriptAggs: string; readonly painless: string; readonly painlessApi: string; + readonly painlessLangSpec: string; readonly painlessSyntax: string; + readonly painlessWalkthrough: string; readonly luceneExpressions: string; }; readonly indexPatterns: { @@ -594,6 +603,7 @@ export interface DocLinksStart { openIndex: string; putComponentTemplate: string; painlessExecute: string; + painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putWatch: string; updateTransform: string; @@ -969,6 +979,8 @@ export interface OverlayModalOpenOptions { className?: string; // (undocumented) closeButtonAriaLabel?: string; + // (undocumented) + maxWidth?: boolean | number | string; } // @public @@ -1197,9 +1209,13 @@ export interface SavedObjectsFindOptions { page?: number; // (undocumented) perPage?: number; + // Warning: (ae-forgotten-export) The symbol "SavedObjectsPitParams" needs to be exported by the entry point index.d.ts + // Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "openPointInTimeForType" + pit?: SavedObjectsPitParams; preference?: string; rootSearchFields?: string[]; search?: string; + searchAfter?: unknown[]; searchFields?: string[]; // (undocumented) sortField?: string; @@ -1369,7 +1385,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public (undocumented) export interface SavedObjectsStart { @@ -1519,6 +1535,7 @@ export interface UiSettingsParams { name?: string; optionLabels?: Record; options?: string[]; + order?: number; readonly?: boolean; requiresPageReload?: boolean; // (undocumented) @@ -1537,7 +1554,7 @@ export interface UiSettingsState { } // @public -export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; // @public export type UnmountCallback = () => void; diff --git a/src/core/public/rendering/_base.scss b/src/core/public/rendering/_base.scss index b806ac270331d2..de13785a17f5b9 100644 --- a/src/core/public/rendering/_base.scss +++ b/src/core/public/rendering/_base.scss @@ -1,4 +1,4 @@ -@include euiHeaderAffordForFixed($kbnHeaderOffset); +@import '../mixins'; /** * stretch the root element of the Kibana application to set the base-size that @@ -15,11 +15,8 @@ display: flex; flex-flow: column nowrap; margin: 0 auto; - min-height: calc(100vh - #{$kbnHeaderOffset}); - &.hidden-chrome { - min-height: 100vh; - } + @include kibanaFullBodyMinHeight(); } .app-wrapper-panel { @@ -33,3 +30,28 @@ flex-shrink: 0; } } + +// adapted from euiHeaderAffordForFixed as we need to handle the top banner +@mixin kbnAffordForHeader($headerHeight) { + padding-top: $headerHeight; + + .euiFlyout, + .euiCollapsibleNav { + top: $headerHeight; + height: calc(100% - #{$headerHeight}); + } +} + +.kbnBody { + @include kbnAffordForHeader($kbnHeaderOffset); + + &.kbnBody--hasHeaderBanner { + @include kbnAffordForHeader($kbnHeaderOffsetWithBanner); + } + &.kbnBody--chromeHidden { + @include kbnAffordForHeader(0); + } + &.kbnBody--chromeHidden.kbnBody--hasHeaderBanner { + @include kbnAffordForHeader($kbnHeaderBannerHeight); + } +} diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 47b340eed8468b..843f2a253f33ec 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -9,6 +9,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; +import { pairwise, startWith } from 'rxjs/operators'; import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; @@ -32,19 +33,27 @@ interface StartDeps { */ export class RenderingService { start({ application, chrome, overlays, targetDomElement }: StartDeps) { - const chromeUi = chrome.getHeaderComponent(); - const appUi = application.getComponent(); - const bannerUi = overlays.banners.getComponent(); + const chromeHeader = chrome.getHeaderComponent(); + const appComponent = application.getComponent(); + const bannerComponent = overlays.banners.getComponent(); + + const body = document.querySelector('body')!; + chrome + .getBodyClasses$() + .pipe(startWith([]), pairwise()) + .subscribe(([previousClasses, newClasses]) => { + body.classList.remove(...previousClasses); + body.classList.add(...newClasses); + }); ReactDOM.render(
- {chromeUi} - + {chromeHeader}
-
{bannerUi}
- {appUi} +
{bannerComponent}
+ {appComponent}
diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 9c0a44b2d3da0b..44466025de7e31 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -21,12 +21,14 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; +type PromiseType> = T extends Promise ? U : never; + type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, - 'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap' + 'pit' | 'rootSearchFields' | 'searchAfter' | 'sortOrder' | 'typeToNamespacesMap' >; -type PromiseType> = T extends Promise ? U : never; +type SavedObjectsFindResponse = Omit>, 'pit_id'>; /** @public */ export interface SavedObjectsCreateOptions { @@ -345,10 +347,7 @@ export class SavedObjectsClient { query, }); return request.then((resp) => { - return renameKeys< - PromiseType>, - SavedObjectsFindResponsePublic - >( + return renameKeys( { saved_objects: 'savedObjects', total: 'total', diff --git a/src/core/server/core_route_handler_context.ts b/src/core/server/core_route_handler_context.ts index f3e06ad8f1daa2..f5123a91e71003 100644 --- a/src/core/server/core_route_handler_context.ts +++ b/src/core/server/core_route_handler_context.ts @@ -13,8 +13,7 @@ import { SavedObjectsClientContract } from './saved_objects/types'; import { InternalSavedObjectsServiceStart, ISavedObjectTypeRegistry, - ISavedObjectsExporter, - ISavedObjectsImporter, + SavedObjectsClientProviderOptions, } from './saved_objects'; import { InternalElasticsearchServiceStart, @@ -58,8 +57,6 @@ class CoreSavedObjectsRouteHandlerContext { ) {} #scopedSavedObjectsClient?: SavedObjectsClientContract; #typeRegistry?: ISavedObjectTypeRegistry; - #exporter?: ISavedObjectsExporter; - #importer?: ISavedObjectsImporter; public get client() { if (this.#scopedSavedObjectsClient == null) { @@ -75,19 +72,18 @@ class CoreSavedObjectsRouteHandlerContext { return this.#typeRegistry; } - public get exporter() { - if (this.#exporter == null) { - this.#exporter = this.savedObjectsStart.createExporter(this.client); - } - return this.#exporter; - } + public getClient = (options?: SavedObjectsClientProviderOptions) => { + if (!options) return this.client; + return this.savedObjectsStart.getScopedClient(this.request, options); + }; - public get importer() { - if (this.#importer == null) { - this.#importer = this.savedObjectsStart.createImporter(this.client); - } - return this.#importer; - } + public getExporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createExporter(client); + }; + + public getImporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createImporter(client); + }; } class CoreUiSettingsRouteHandlerContext { diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts index 1a0706917b5dd8..21a599e45da013 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -105,6 +105,7 @@ const createStartContractMock = () => { loggersConfiguredCount: 0, }, savedObjects: { + customIndex: false, maxImportExportSizeBytes: 10000, maxImportPayloadBytes: 26214400, }, diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index 5a9a68c9e4ece8..9086d73b778071 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -238,6 +238,7 @@ describe('CoreUsageDataService', () => { "loggersConfiguredCount": 0, }, "savedObjects": Object { + "customIndex": false, "maxImportExportSizeBytes": 10000, "maxImportPayloadBytes": 26214400, }, diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index a4c6c6e8c66f46..bd5f23b1c09bc7 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -59,6 +59,19 @@ const kibanaOrTaskManagerIndex = (index: string, kibanaConfigIndex: string) => { return index === kibanaConfigIndex ? '.kibana' : '.kibana_task_manager'; }; +/** + * This is incredibly hacky... The config service doesn't allow you to determine + * whether or not a config value has been changed from the default value, and the + * default value is defined in legacy code. + * + * This will be going away in 8.0, so please look away for a few months + * + * @param index The `kibana.index` setting from the `kibana.yml` + */ +const isCustomIndex = (index: string) => { + return index !== '.kibana'; +}; + export class CoreUsageDataService implements CoreService { private logger: Logger; private elasticsearchConfig?: ElasticsearchConfigType; @@ -220,6 +233,7 @@ export class CoreUsageDataService implements CoreService { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; aggregations?: any; + pit_id?: string; } /** diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 6f478004c204ef..8e4cdc7d59e322 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -49,6 +49,7 @@ import { SavedObjectsServiceStart, ISavedObjectsExporter, ISavedObjectsImporter, + SavedObjectsClientProviderOptions, } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; @@ -260,6 +261,8 @@ export { SavedObjectsClientWrapperOptions, SavedObjectsClientFactory, SavedObjectsClientFactoryProvider, + SavedObjectsClosePointInTimeOptions, + SavedObjectsClosePointInTimeResponse, SavedObjectsCreateOptions, SavedObjectsErrorHelpers, SavedObjectsExportResultDetails, @@ -277,6 +280,8 @@ export { SavedObjectsImportUnsupportedTypeError, SavedObjectMigrationContext, SavedObjectsMigrationLogger, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsOpenPointInTimeResponse, SavedObjectsRawDoc, SavedObjectsRawDocParseOptions, SavedObjectSanitizedDoc, @@ -373,6 +378,7 @@ export { SavedObjectsClientContract, SavedObjectsFindOptions, SavedObjectsFindOptionsReference, + SavedObjectsPitParams, SavedObjectsMigrationVersion, } from './types'; @@ -410,8 +416,9 @@ export interface RequestHandlerContext { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts index 714485da1654ac..fb2a714adb687a 100644 --- a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts +++ b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts @@ -48,11 +48,10 @@ describe('RollingFileAppender', () => { }); afterEach(async () => { - try { - await rmdir(testDir); - } catch (e) { - /* trap */ + if (testDir) { + await rmdir(testDir, { recursive: true }); } + if (root) { await root.shutdown(); } diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index a4ce94b1776128..19056ae1b9bc76 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -196,8 +196,9 @@ function createCoreRequestHandlerContextMock() { savedObjects: { client: savedObjectsClientMock.create(), typeRegistry: savedObjectsTypeRegistryMock.create(), - exporter: savedObjectsServiceMock.createExporter(), - importer: savedObjectsServiceMock.createImporter(), + getClient: savedObjectsClientMock.create, + getExporter: savedObjectsServiceMock.createExporter, + getImporter: savedObjectsServiceMock.createImporter, }, elasticsearch: { client: elasticsearchServiceMock.createScopedClusterClient(), diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts index 4dc912680ec631..f3a92c896b0148 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.test.ts @@ -9,12 +9,10 @@ import { mockReadFile } from './plugin_manifest_parser.test.mocks'; import { PluginDiscoveryErrorType } from './plugin_discovery_error'; -import { loggingSystemMock } from '../../logging/logging_system.mock'; import { resolve } from 'path'; import { parseManifest } from './plugin_manifest_parser'; -const logger = loggingSystemMock.createLogger(); const pluginPath = resolve('path', 'existent-dir'); const pluginManifestPath = resolve(pluginPath, 'kibana.json'); const packageInfo = { @@ -34,7 +32,7 @@ test('return error when manifest is empty', async () => { cb(null, Buffer.from('')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Unexpected end of JSON input (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -46,7 +44,7 @@ test('return error when manifest content is null', async () => { cb(null, Buffer.from('null')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest must contain a JSON encoded object. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -58,7 +56,7 @@ test('return error when manifest content is not a valid JSON', async () => { cb(null, Buffer.from('not-json')); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Unexpected token o in JSON at position 1 (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -70,7 +68,7 @@ test('return error when plugin id is missing', async () => { cb(null, Buffer.from(JSON.stringify({ version: 'some-version' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest must contain an "id" property. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -82,37 +80,24 @@ test('return error when plugin id includes `.` characters', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'some.name', version: 'some-version' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "id" must not include \`.\` characters. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, }); }); -test('logs warning if pluginId is not in camelCase format', async () => { +test('return error when pluginId is not in camelCase format', async () => { + expect.assertions(1); mockReadFile.mockImplementation((path, cb) => { cb(null, Buffer.from(JSON.stringify({ id: 'some_name', version: 'kibana', server: true }))); }); - expect(loggingSystemMock.collect(logger).warn).toHaveLength(0); - await parseManifest(pluginPath, packageInfo, logger); - expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` - Array [ - Array [ - "Expect plugin \\"id\\" in camelCase, but found: some_name", - ], - ] - `); -}); - -test('does not log pluginId format warning in dist mode', async () => { - mockReadFile.mockImplementation((path, cb) => { - cb(null, Buffer.from(JSON.stringify({ id: 'some_name', version: 'kibana', server: true }))); + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ + message: `Plugin "id" must be camelCase, but found: some_name. (invalid-manifest, ${pluginManifestPath})`, + type: PluginDiscoveryErrorType.InvalidManifest, + path: pluginManifestPath, }); - - expect(loggingSystemMock.collect(logger).warn).toHaveLength(0); - await parseManifest(pluginPath, { ...packageInfo, dist: true }, logger); - expect(loggingSystemMock.collect(logger).warn.length).toBe(0); }); test('return error when plugin version is missing', async () => { @@ -120,7 +105,7 @@ test('return error when plugin version is missing', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin manifest for "someId" must contain a "version" property. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -132,7 +117,7 @@ test('return error when plugin expected Kibana version is lower than actual vers cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '6.4.2' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "6.4.2", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -147,7 +132,7 @@ test('return error when plugin expected Kibana version cannot be interpreted as ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "non-sem-ver", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -159,7 +144,7 @@ test('return error when plugin config path is not a string', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', configPath: 2 }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `The "configPath" in plugin manifest for "someId" should either be a string or an array of strings. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -174,7 +159,7 @@ test('return error when plugin config path is an array that contains non-string ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `The "configPath" in plugin manifest for "someId" should either be a string or an array of strings. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -186,7 +171,7 @@ test('return error when plugin expected Kibana version is higher than actual ver cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.1' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Plugin "someId" is only compatible with Kibana version "7.0.1", but used Kibana version is "7.0.0-alpha1". (incompatible-version, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.IncompatibleVersion, path: pluginManifestPath, @@ -198,7 +183,7 @@ test('return error when both `server` and `ui` are set to `false` or missing', a cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0' }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Both "server" and "ui" are missing or set to "false" in plugin manifest for "someId", but at least one of these must be set to "true". (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -211,7 +196,7 @@ test('return error when both `server` and `ui` are set to `false` or missing', a ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Both "server" and "ui" are missing or set to "false" in plugin manifest for "someId", but at least one of these must be set to "true". (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -234,7 +219,7 @@ test('return error when manifest contains unrecognized properties', async () => ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).rejects.toMatchObject({ + await expect(parseManifest(pluginPath, packageInfo)).rejects.toMatchObject({ message: `Manifest for plugin "someId" contains the following unrecognized properties: unknownOne,unknownTwo. (invalid-manifest, ${pluginManifestPath})`, type: PluginDiscoveryErrorType.InvalidManifest, path: pluginManifestPath, @@ -247,20 +232,20 @@ describe('configPath', () => { cb(null, Buffer.from(JSON.stringify({ id: 'plugin', version: '7.0.0', server: true }))); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe(manifest.id); }); test('falls back to plugin id in snakeCase format', async () => { mockReadFile.mockImplementation((path, cb) => { - cb(null, Buffer.from(JSON.stringify({ id: 'SomeId', version: '7.0.0', server: true }))); + cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', server: true }))); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe('some_id'); }); - test('not formated to snakeCase if defined explicitly as string', async () => { + test('not formatted to snakeCase if defined explicitly as string', async () => { mockReadFile.mockImplementation((path, cb) => { cb( null, @@ -270,11 +255,11 @@ describe('configPath', () => { ); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toBe('somePath'); }); - test('not formated to snakeCase if defined explicitly as an array of strings', async () => { + test('not formatted to snakeCase if defined explicitly as an array of strings', async () => { mockReadFile.mockImplementation((path, cb) => { cb( null, @@ -284,7 +269,7 @@ describe('configPath', () => { ); }); - const manifest = await parseManifest(pluginPath, packageInfo, logger); + const manifest = await parseManifest(pluginPath, packageInfo); expect(manifest.configPath).toEqual(['somePath']); }); }); @@ -294,7 +279,7 @@ test('set defaults for all missing optional fields', async () => { cb(null, Buffer.from(JSON.stringify({ id: 'someId', version: '7.0.0', server: true }))); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some_id', version: '7.0.0', @@ -325,7 +310,7 @@ test('return all set optional fields as they are in manifest', async () => { ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: ['some', 'path'], version: 'some-version', @@ -355,7 +340,7 @@ test('return manifest when plugin expected Kibana version matches actual version ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some-path', version: 'some-version', @@ -385,7 +370,7 @@ test('return manifest when plugin expected Kibana version is `kibana`', async () ); }); - await expect(parseManifest(pluginPath, packageInfo, logger)).resolves.toEqual({ + await expect(parseManifest(pluginPath, packageInfo)).resolves.toEqual({ id: 'someId', configPath: 'some_id', version: 'some-version', diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.ts index 9db68bcaa4ccea..eae0e73e86c465 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.ts @@ -12,7 +12,6 @@ import { coerce } from 'semver'; import { promisify } from 'util'; import { snakeCase } from 'lodash'; import { isConfigPath, PackageInfo } from '../../config'; -import { Logger } from '../../logging'; import { PluginManifest } from '../types'; import { PluginDiscoveryError } from './plugin_discovery_error'; import { isCamelCase } from './is_camel_case'; @@ -63,8 +62,7 @@ const KNOWN_MANIFEST_FIELDS = (() => { */ export async function parseManifest( pluginPath: string, - packageInfo: PackageInfo, - log: Logger + packageInfo: PackageInfo ): Promise { const manifestPath = resolve(pluginPath, MANIFEST_FILE_NAME); @@ -105,8 +103,11 @@ export async function parseManifest( ); } - if (!packageInfo.dist && !isCamelCase(manifest.id)) { - log.warn(`Expect plugin "id" in camelCase, but found: ${manifest.id}`); + if (!isCamelCase(manifest.id)) { + throw PluginDiscoveryError.invalidManifest( + manifestPath, + new Error(`Plugin "id" must be camelCase, but found: ${manifest.id}.`) + ); } if (!manifest.version || typeof manifest.version !== 'string') { diff --git a/src/core/server/plugins/discovery/plugins_discovery.ts b/src/core/server/plugins/discovery/plugins_discovery.ts index 61eccff982593b..368795968a7cbc 100644 --- a/src/core/server/plugins/discovery/plugins_discovery.ts +++ b/src/core/server/plugins/discovery/plugins_discovery.ts @@ -179,7 +179,7 @@ function createPlugin$( coreContext: CoreContext, instanceInfo: InstanceInfo ) { - return from(parseManifest(path, coreContext.env.packageInfo, log)).pipe( + return from(parseManifest(path, coreContext.env.packageInfo)).pipe( map((manifest) => { log.debug(`Successfully discovered plugin "${manifest.id}" at "${path}"`); const opaqueId = Symbol(manifest.id); diff --git a/src/core/server/saved_objects/export/point_in_time_finder.test.ts b/src/core/server/saved_objects/export/point_in_time_finder.test.ts new file mode 100644 index 00000000000000..cd79c7a4b81e5a --- /dev/null +++ b/src/core/server/saved_objects/export/point_in_time_finder.test.ts @@ -0,0 +1,321 @@ +/* + * 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 { savedObjectsClientMock } from '../service/saved_objects_client.mock'; +import { loggerMock, MockedLogger } from '../../logging/logger.mock'; +import { SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResult } from '../service'; + +import { createPointInTimeFinder } from './point_in_time_finder'; + +const mockHits = [ + { + id: '2', + type: 'search', + attributes: {}, + score: 1, + references: [ + { + name: 'name', + type: 'visualization', + id: '1', + }, + ], + sort: [], + }, + { + id: '1', + type: 'visualization', + attributes: {}, + score: 1, + references: [], + sort: [], + }, +]; + +describe('createPointInTimeFinder()', () => { + let logger: MockedLogger; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + logger = loggerMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + describe('#find', () => { + test('throws if a PIT is already open', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 1, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + await finder.find().next(); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + savedObjectsClient.find.mockClear(); + + expect(async () => { + await finder.find().next(); + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Point In Time has already been opened for this finder instance. Please call \`close()\` before calling \`find()\` again."` + ); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(0); + }); + + test('works with a single page of results', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 2, + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + + expect(hits.length).toBe(2); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['visualization'], + }) + ); + }); + + test('works with multiple pages of results', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[0]], + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[1]], + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [], + per_page: 1, + pit_id: 'abc123', + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + + expect(hits.length).toBe(2); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + // called 3 times since we need a 3rd request to check if we + // are done paginating through results. + expect(savedObjectsClient.find).toHaveBeenCalledTimes(3); + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['visualization'], + }) + ); + }); + }); + + describe('#close', () => { + test('calls closePointInTime with correct ID', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + saved_objects: [mockHits[0]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 2, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + await finder.close(); + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledWith('test'); + }); + + test('causes generator to stop', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[0]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[1]], + pit_id: 'test', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [], + per_page: 1, + pit_id: 'test', + page: 0, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + await finder.close(); + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + expect(hits.length).toBe(1); + }); + + test('is called if `find` throws an error', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'test', + }); + savedObjectsClient.find.mockRejectedValueOnce(new Error('oops')); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 2, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + const hits: SavedObjectsFindResult[] = []; + try { + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + } catch (e) { + // intentionally empty + } + + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledWith('test'); + }); + + test('finder can be reused after closing', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 1, + }); + + const findOptions: SavedObjectsFindOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const finder = createPointInTimeFinder({ findOptions, logger, savedObjectsClient }); + + const findA = finder.find(); + await findA.next(); + await finder.close(); + + const findB = finder.find(); + await findB.next(); + await finder.close(); + + expect((await findA.next()).done).toBe(true); + expect((await findB.next()).done).toBe(true); + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/core/server/saved_objects/export/point_in_time_finder.ts b/src/core/server/saved_objects/export/point_in_time_finder.ts new file mode 100644 index 00000000000000..dc0bac6b6bfd99 --- /dev/null +++ b/src/core/server/saved_objects/export/point_in_time_finder.ts @@ -0,0 +1,192 @@ +/* + * 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 { Logger } from '../../logging'; +import { SavedObjectsClientContract, SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResponse } from '../service'; + +/** + * Returns a generator to help page through large sets of saved objects. + * + * The generator wraps calls to `SavedObjects.find` and iterates over + * multiple pages of results using `_pit` and `search_after`. This will + * open a new Point In Time (PIT), and continue paging until a set of + * results is received that's smaller than the designated `perPage`. + * + * Once you have retrieved all of the results you need, it is recommended + * to call `close()` to clean up the PIT and prevent Elasticsearch from + * consuming resources unnecessarily. This will automatically be done for + * you if you reach the last page of results. + * + * @example + * ```ts + * const findOptions: SavedObjectsFindOptions = { + * type: 'visualization', + * search: 'foo*', + * perPage: 100, + * }; + * + * const finder = createPointInTimeFinder({ + * logger, + * savedObjectsClient, + * findOptions, + * }); + * + * const responses: SavedObjectFindResponse[] = []; + * for await (const response of finder.find()) { + * responses.push(...response); + * if (doneSearching) { + * await finder.close(); + * } + * } + * ``` + */ +export function createPointInTimeFinder({ + findOptions, + logger, + savedObjectsClient, +}: { + findOptions: SavedObjectsFindOptions; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; +}) { + return new PointInTimeFinder({ findOptions, logger, savedObjectsClient }); +} + +/** + * @internal + */ +export class PointInTimeFinder { + readonly #log: Logger; + readonly #savedObjectsClient: SavedObjectsClientContract; + readonly #findOptions: SavedObjectsFindOptions; + #open: boolean = false; + #pitId?: string; + + constructor({ + findOptions, + logger, + savedObjectsClient, + }: { + findOptions: SavedObjectsFindOptions; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; + }) { + this.#log = logger; + this.#savedObjectsClient = savedObjectsClient; + this.#findOptions = { + // Default to 1000 items per page as a tradeoff between + // speed and memory consumption. + perPage: 1000, + ...findOptions, + }; + } + + async *find() { + if (this.#open) { + throw new Error( + 'Point In Time has already been opened for this finder instance. ' + + 'Please call `close()` before calling `find()` again.' + ); + } + + // Open PIT and request our first page of hits + await this.open(); + + let lastResultsCount: number; + let lastHitSortValue: unknown[] | undefined; + do { + const results = await this.findNext({ + findOptions: this.#findOptions, + id: this.#pitId, + ...(lastHitSortValue ? { searchAfter: lastHitSortValue } : {}), + }); + this.#pitId = results.pit_id; + lastResultsCount = results.saved_objects.length; + lastHitSortValue = this.getLastHitSortValue(results); + + this.#log.debug(`Collected [${lastResultsCount}] saved objects for export.`); + + // Close PIT if this was our last page + if (this.#pitId && lastResultsCount < this.#findOptions.perPage!) { + await this.close(); + } + + yield results; + // We've reached the end when there are fewer hits than our perPage size, + // or when `close()` has been called. + } while (this.#open && lastResultsCount >= this.#findOptions.perPage!); + + return; + } + + async close() { + try { + if (this.#pitId) { + this.#log.debug(`Closing PIT for types [${this.#findOptions.type}]`); + await this.#savedObjectsClient.closePointInTime(this.#pitId); + this.#pitId = undefined; + } + this.#open = false; + } catch (e) { + this.#log.error(`Failed to close PIT for types [${this.#findOptions.type}]`); + throw e; + } + } + + private async open() { + try { + const { id } = await this.#savedObjectsClient.openPointInTimeForType(this.#findOptions.type); + this.#pitId = id; + this.#open = true; + } catch (e) { + // Since `find` swallows 404s, it is expected that exporter will do the same, + // so we only rethrow non-404 errors here. + if (e.output.statusCode !== 404) { + throw e; + } + this.#log.debug(`Unable to open PIT for types [${this.#findOptions.type}]: 404 ${e}`); + } + } + + private async findNext({ + findOptions, + id, + searchAfter, + }: { + findOptions: SavedObjectsFindOptions; + id?: string; + searchAfter?: unknown[]; + }) { + try { + return await this.#savedObjectsClient.find({ + // Sort fields are required to use searchAfter, so we set some defaults here + sortField: 'updated_at', + sortOrder: 'desc', + // Bump keep_alive by 2m on every new request to allow for the ES client + // to make multiple retries in the event of a network failure. + ...(id ? { pit: { id, keepAlive: '2m' } } : {}), + ...(searchAfter ? { searchAfter } : {}), + ...findOptions, + }); + } catch (e) { + if (id) { + // Clean up PIT on any errors. + await this.close(); + } + throw e; + } + } + + private getLastHitSortValue(res: SavedObjectsFindResponse): unknown[] | undefined { + if (res.saved_objects.length < 1) { + return undefined; + } + return res.saved_objects[res.saved_objects.length - 1].sort; + } +} diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts index c16623f785b08f..cf60ada5ba90ac 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts @@ -11,6 +11,7 @@ import { SavedObjectsExporter } from './saved_objects_exporter'; import { savedObjectsClientMock } from '../service/saved_objects_client.mock'; import { SavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { httpServerMock } from '../../http/http_server.mocks'; +import { loggerMock, MockedLogger } from '../../logging/logger.mock'; import { Readable } from 'stream'; import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; @@ -18,18 +19,25 @@ async function readStreamToCompletion(stream: Readable): Promise { + let logger: MockedLogger; let savedObjectsClient: ReturnType; let typeRegistry: SavedObjectTypeRegistry; let exporter: SavedObjectsExporter; beforeEach(() => { + logger = loggerMock.create(); typeRegistry = new SavedObjectTypeRegistry(); savedObjectsClient = savedObjectsClientMock.create(); - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + }); }); describe('#exportByTypes', () => { @@ -58,7 +66,7 @@ describe('getSortedObjectsForExport()', () => { references: [], }, ], - per_page: 1, + per_page: 1000, page: 0, }); const exportStream = await exporter.exportByTypes({ @@ -96,30 +104,232 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); + }); + + describe('pages through results with PIT', () => { + function generateHits( + hitCount: number, + { + attributes = {}, + sort = [], + type = 'index-pattern', + }: { + attributes?: Record; + sort?: unknown[]; + type?: string; + } = {} + ) { + const hits = []; + for (let i = 1; i <= hitCount; i++) { + hits.push({ + id: `${i}`, + type, + attributes, + sort, + score: 1, + references: [], + }); + } + return hits; + } + + describe('<1k hits', () => { + const mockHits = generateHits(20); + + test('requests a single page', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(response[response.length - 1]).toMatchInlineSnapshot(` + Object { + "exportedCount": 20, + "missingRefCount": 0, + "missingReferences": Array [], + } + `); + }); + + test('opens and closes PIT', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + pit_id: 'abc123', + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + test('passes correct PIT ID to `find`', async () => { + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 20, + saved_objects: mockHits, + per_page: 1000, + page: 0, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['index-pattern'], + }) + ); + }); + }); + + describe('>1k hits', () => { + const firstMockHits = generateHits(1000, { sort: ['a', 'b'] }); + const secondMockHits = generateHits(500); + + test('requests multiple pages', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(2); + expect(response[response.length - 1]).toMatchInlineSnapshot(` + Object { + "exportedCount": 1500, + "missingRefCount": 0, + "missingReferences": Array [], + } `); + }); + + test('opens and closes PIT', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + pit_id: 'abc123', + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + pit_id: 'abc123', + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); + }); + + test('passes sort values to searchAfter', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: firstMockHits, + per_page: 1000, + page: 0, + }); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1500, + saved_objects: secondMockHits, + per_page: 500, + page: 1, + }); + + const exportStream = await exporter.exportByTypes({ + request, + types: ['index-pattern'], + }); + + await readStreamToCompletion(exportStream); + + expect(savedObjectsClient.find.mock.calls[1][0]).toEqual( + expect.objectContaining({ + searchAfter: ['a', 'b'], + }) + ); + }); + }); }); test('applies the export transforms', async () => { @@ -138,7 +348,12 @@ describe('getSortedObjectsForExport()', () => { }, }, }); - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit, + logger, + savedObjectsClient, + typeRegistry, + }); savedObjectsClient.find.mockResolvedValueOnce({ total: 1, @@ -233,30 +448,36 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exclude export details if option is specified', async () => { @@ -383,30 +604,36 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": undefined, - "perPage": 500, - "search": "foo", - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": "foo", + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exports selected types with references when present', async () => { @@ -465,35 +692,41 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - "hasReferenceOperator": "OR", - "namespaces": undefined, - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], - ], - "results": Array [ + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": Array [ Object { - "type": "return", - "value": Promise {}, + "id": "1", + "type": "index-pattern", }, ], - } - `); + "hasReferenceOperator": "OR", + "namespaces": undefined, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", + ], + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('exports from the provided namespace when present', async () => { @@ -521,7 +754,7 @@ describe('getSortedObjectsForExport()', () => { references: [], }, ], - per_page: 1, + per_page: 1000, page: 0, }); const exportStream = await exporter.exportByTypes({ @@ -560,36 +793,56 @@ describe('getSortedObjectsForExport()', () => { ] `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Object { - "hasReference": undefined, - "hasReferenceOperator": undefined, - "namespaces": Array [ - "foo", - ], - "perPage": 500, - "search": undefined, - "type": Array [ - "index-pattern", - "search", - ], - }, - ], + [MockFunction] { + "calls": Array [ + Array [ + Object { + "hasReference": undefined, + "hasReferenceOperator": undefined, + "namespaces": Array [ + "foo", ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, + "perPage": 1000, + "pit": Object { + "id": "some_pit_id", + "keepAlive": "2m", + }, + "search": undefined, + "sortField": "updated_at", + "sortOrder": "desc", + "type": Array [ + "index-pattern", + "search", ], - } - `); + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('export selected types throws error when exceeding exportSizeLimit', async () => { - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit: 1, + logger, + savedObjectsClient, + typeRegistry, + }); + + savedObjectsClient.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + + savedObjectsClient.closePointInTime.mockResolvedValueOnce({ + succeeded: true, + num_freed: 1, + }); savedObjectsClient.find.mockResolvedValueOnce({ total: 2, @@ -617,6 +870,7 @@ describe('getSortedObjectsForExport()', () => { ], per_page: 1, page: 0, + pit_id: 'abc123', }); await expect( exporter.exportByTypes({ @@ -624,12 +878,13 @@ describe('getSortedObjectsForExport()', () => { types: ['index-pattern', 'search'], }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't export more than 1 objects"`); + expect(savedObjectsClient.closePointInTime).toHaveBeenCalledTimes(1); }); test('sorts objects within type', async () => { savedObjectsClient.find.mockResolvedValueOnce({ total: 3, - per_page: 10000, + per_page: 1000, page: 1, saved_objects: [ { @@ -836,7 +1091,12 @@ describe('getSortedObjectsForExport()', () => { }); test('export selected objects throws error when exceeding exportSizeLimit', async () => { - exporter = new SavedObjectsExporter({ savedObjectsClient, exportSizeLimit: 1, typeRegistry }); + exporter = new SavedObjectsExporter({ + exportSizeLimit: 1, + logger, + savedObjectsClient, + typeRegistry, + }); const exportOpts = { request, diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts index 295a3d7a119d4e..c1c0ea73f0bd3d 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -8,7 +8,9 @@ import { createListStream } from '@kbn/utils'; import { PublicMethodsOf } from '@kbn/utility-types'; -import { SavedObject, SavedObjectsClientContract } from '../types'; +import { Logger } from '../../logging'; +import { SavedObject, SavedObjectsClientContract, SavedObjectsFindOptions } from '../types'; +import { SavedObjectsFindResult } from '../service'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { fetchNestedDependencies } from './fetch_nested_dependencies'; import { sortObjects } from './sort_objects'; @@ -21,6 +23,7 @@ import { } from './types'; import { SavedObjectsExportError } from './errors'; import { applyExportTransforms } from './apply_export_transforms'; +import { createPointInTimeFinder } from './point_in_time_finder'; import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils'; /** @@ -35,16 +38,20 @@ export class SavedObjectsExporter { readonly #savedObjectsClient: SavedObjectsClientContract; readonly #exportTransforms: Record; readonly #exportSizeLimit: number; + readonly #log: Logger; constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, + logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }) { + this.#log = logger; this.#savedObjectsClient = savedObjectsClient; this.#exportSizeLimit = exportSizeLimit; this.#exportTransforms = typeRegistry.getAllTypes().reduce((transforms, type) => { @@ -66,6 +73,7 @@ export class SavedObjectsExporter { * @throws SavedObjectsExportError */ public async exportByTypes(options: SavedObjectsExportByTypeOptions) { + this.#log.debug(`Initiating export for types: [${options.types}]`); const objects = await this.fetchByTypes(options); return this.processObjects(objects, byIdAscComparator, { request: options.request, @@ -83,6 +91,7 @@ export class SavedObjectsExporter { * @throws SavedObjectsExportError */ public async exportByObjects(options: SavedObjectsExportByObjectOptions) { + this.#log.debug(`Initiating export of [${options.objects.length}] objects.`); if (options.objects.length > this.#exportSizeLimit) { throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); } @@ -106,6 +115,7 @@ export class SavedObjectsExporter { namespace, }: SavedObjectExportBaseOptions ) { + this.#log.debug(`Processing [${savedObjects.length}] saved objects.`); let exportedObjects: Array>; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; @@ -117,6 +127,7 @@ export class SavedObjectsExporter { }); if (includeReferencesDeep) { + this.#log.debug(`Fetching saved objects references.`); const fetchResult = await fetchNestedDependencies( savedObjects, this.#savedObjectsClient, @@ -138,6 +149,7 @@ export class SavedObjectsExporter { missingRefCount: missingReferences.length, missingReferences, }; + this.#log.debug(`Exporting [${redactedObjects.length}] saved objects.`); return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); } @@ -156,21 +168,32 @@ export class SavedObjectsExporter { hasReference, search, }: SavedObjectsExportByTypeOptions) { - const findResponse = await this.#savedObjectsClient.find({ + const findOptions: SavedObjectsFindOptions = { type: types, hasReference, hasReferenceOperator: hasReference ? 'OR' : undefined, search, - perPage: this.#exportSizeLimit, namespaces: namespace ? [namespace] : undefined, + }; + + const finder = createPointInTimeFinder({ + findOptions, + logger: this.#log, + savedObjectsClient: this.#savedObjectsClient, }); - if (findResponse.total > this.#exportSizeLimit) { - throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); + + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + if (hits.length > this.#exportSizeLimit) { + await finder.close(); + throw SavedObjectsExportError.exportSizeExceeded(this.#exportSizeLimit); + } } // sorts server-side by _id, since it's only available in fielddata return ( - findResponse.saved_objects + hits // exclude the find-specific `score` property from the exported objects .map(({ score, ...obj }) => obj) .sort(byIdAscComparator) diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 776c7b195922e1..f29a8b61b48858 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -143,7 +143,7 @@ describe('DocumentMigrator', () => { ).toThrow(/Migrations are not ready. Make sure prepareMigrations is called first./i); }); - it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple'`, () => { + it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple' or 'multiple-isolated'`, () => { const invalidDefinition = { kibanaVersion: '3.2.3', typeRegistry: createRegistry({ @@ -154,7 +154,7 @@ describe('DocumentMigrator', () => { log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrow( - `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple', but got 'single'.` + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got 'single'.` ); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index b61c4cfe967e71..47f4dda75cdcd7 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -312,9 +312,9 @@ function validateMigrationDefinition( convertToMultiNamespaceTypeVersion: string, type: string ) { - if (namespaceType !== 'multiple') { + if (namespaceType !== 'multiple' && namespaceType !== 'multiple-isolated') { throw new Error( - `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple', but got '${namespaceType}'.` + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got '${namespaceType}'.` ); } else if (!Semver.valid(convertToMultiNamespaceTypeVersion)) { throw new Error( @@ -374,7 +374,7 @@ function buildActiveMigrations( const migrationTransforms = Object.entries(migrationsMap ?? {}).map( ([version, transform]) => ({ version, - transform: wrapWithTry(version, type.name, transform, log), + transform: wrapWithTry(version, type, transform, log), transformType: 'migrate', }) ); @@ -655,24 +655,28 @@ function transformComparator(a: Transform, b: Transform) { */ function wrapWithTry( version: string, - type: string, + type: SavedObjectsType, migrationFn: SavedObjectMigrationFn, log: Logger ) { return function tryTransformDoc(doc: SavedObjectUnsanitizedDoc) { try { - const context = { log: new MigrationLogger(log) }; + const context = { + log: new MigrationLogger(log), + migrationVersion: version, + convertToMultiNamespaceTypeVersion: type.convertToMultiNamespaceTypeVersion, + }; const result = migrationFn(doc, context); // A basic sanity check to help migration authors detect basic errors // (e.g. forgetting to return the transformed doc) if (!result || !result.type) { - throw new Error(`Invalid saved object returned from migration ${type}:${version}.`); + throw new Error(`Invalid saved object returned from migration ${type.name}:${version}.`); } return { transformedDoc: result, additionalDocs: [] }; } catch (error) { - const failedTransform = `${type}:${version}`; + const failedTransform = `${type.name}:${version}`; const failedDoc = JSON.stringify(doc); log.warn( `Failed to transform document ${doc?.id}. Transform: ${failedTransform}\nDoc: ${failedDoc}` diff --git a/src/core/server/saved_objects/migrations/mocks.ts b/src/core/server/saved_objects/migrations/mocks.ts index f0360ec180d6e4..4a62fcc95997bb 100644 --- a/src/core/server/saved_objects/migrations/mocks.ts +++ b/src/core/server/saved_objects/migrations/mocks.ts @@ -21,9 +21,17 @@ export const createSavedObjectsMigrationLoggerMock = (): jest.Mocked => { +const createContextMock = ({ + migrationVersion = '8.0.0', + convertToMultiNamespaceTypeVersion, +}: { + migrationVersion?: string; + convertToMultiNamespaceTypeVersion?: string; +} = {}): jest.Mocked => { const mock = { log: createSavedObjectsMigrationLoggerMock(), + migrationVersion, + convertToMultiNamespaceTypeVersion, }; return mock; }; diff --git a/src/core/server/saved_objects/migrations/types.ts b/src/core/server/saved_objects/migrations/types.ts index 630be58eb047dc..619a7f85a327b3 100644 --- a/src/core/server/saved_objects/migrations/types.ts +++ b/src/core/server/saved_objects/migrations/types.ts @@ -57,6 +57,14 @@ export interface SavedObjectMigrationContext { * logger instance to be used by the migration handler */ log: SavedObjectsMigrationLogger; + /** + * The migration version that this migration function is defined for + */ + migrationVersion: string; + /** + * The version in which this object type is being converted to a multi-namespace type + */ + convertToMultiNamespaceTypeVersion?: string; } /** diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index 609ce2692c7770..fe08acf23fd238 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -32,11 +32,13 @@ export const registerDeleteRoute = (router: IRouter, { coreUsageData }: RouteDep catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { force } = req.query; + const { getClient } = context.core.savedObjects; const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsDelete({ request: req }).catch(() => {}); - const result = await context.core.savedObjects.client.delete(type, id, { force }); + const client = getClient(); + const result = await client.delete(type, id, { force }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index fa5517303f18f2..e0293a4522fc14 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -165,9 +165,9 @@ export const registerExportRoute = ( }, catchAndReturnBoomErrors(async (context, req, res) => { const cleaned = cleanOptions(req.body); - const supportedTypes = context.core.savedObjects.typeRegistry - .getImportableAndExportableTypes() - .map((t) => t.name); + const { typeRegistry, getExporter, getClient } = context.core.savedObjects; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((t) => t.name); + let options: EitherExportOptions; try { options = validateOptions(cleaned, { @@ -181,7 +181,12 @@ export const registerExportRoute = ( }); } - const exporter = context.core.savedObjects.exporter; + const includedHiddenTypes = supportedTypes.filter((supportedType) => + typeRegistry.isHidden(supportedType) + ); + + const client = getClient({ includedHiddenTypes }); + const exporter = getExporter(client); const usageStatsClient = coreUsageData.getClient(); usageStatsClient diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index e84c638d3ec999..6f75bcf9fd5bf2 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -63,6 +63,7 @@ export const registerImportRoute = ( }, catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite, createNewCopies } = req.query; + const { getClient, getImporter, typeRegistry } = context.core.savedObjects; const usageStatsClient = coreUsageData.getClient(); usageStatsClient @@ -84,7 +85,15 @@ export const registerImportRoute = ( }); } - const { importer } = context.core.savedObjects; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((t) => t.name); + + const includedHiddenTypes = supportedTypes.filter((supportedType) => + typeRegistry.isHidden(supportedType) + ); + + const client = getClient({ includedHiddenTypes }); + const importer = getImporter(client); + try { const result = await importer.import({ readStream, diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index 7b7a71b7ca858e..eaec6e16cbd8ca 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -26,7 +26,8 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); - savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient = handlerContext.savedObjects.getClient(); + handlerContext.savedObjects.getClient = jest.fn().mockImplementation(() => savedObjectsClient); const router = httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index 40f13064b53f08..09d475f29f3629 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -40,9 +40,13 @@ describe('POST /api/saved_objects/_export', () => { handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); - exporter = handlerContext.savedObjects.exporter; + exporter = handlerContext.savedObjects.getExporter(); const router = httpSetup.createRouter('/api/saved_objects/'); + handlerContext.savedObjects.getExporter = jest + .fn() + .mockImplementation(() => exporter as ReturnType); + coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsExport.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); @@ -77,6 +81,7 @@ describe('POST /api/saved_objects/_export', () => { ], }, ]; + exporter.exportByTypes.mockResolvedValueOnce(createListStream(sortedObjects)); const result = await supertest(httpSetup.server.listener) diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 24122c61c9f42d..be4d2160a967be 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -68,9 +68,9 @@ describe(`POST ${URL}`, () => { typeRegistry: handlerContext.savedObjects.typeRegistry, importSizeLimit: 10000, }); - handlerContext.savedObjects.importer.import.mockImplementation((options) => - importer.import(options) - ); + handlerContext.savedObjects.getImporter = jest + .fn() + .mockImplementation(() => importer as jest.Mocked); const router = httpSetup.createRouter('/internal/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index b23211aef092fc..d84b56156b5434 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -66,7 +66,7 @@ describe(`POST ${URL}`, () => { } as any) ); - savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient = handlerContext.savedObjects.getClient(); savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const importer = new SavedObjectsImporter({ @@ -74,9 +74,10 @@ describe(`POST ${URL}`, () => { typeRegistry: handlerContext.savedObjects.typeRegistry, importSizeLimit: 10000, }); - handlerContext.savedObjects.importer.resolveImportErrors.mockImplementation((options) => - importer.resolveImportErrors(options) - ); + + handlerContext.savedObjects.getImporter = jest + .fn() + .mockImplementation(() => importer as jest.Mocked); const router = httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 2a664328d4df29..a05c7d30b91fdc 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -9,6 +9,7 @@ import { extname } from 'path'; import { Readable } from 'stream'; import { schema } from '@kbn/config-schema'; +import { chain } from 'lodash'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; @@ -91,7 +92,18 @@ export const registerResolveImportErrorsRoute = ( }); } - const { importer } = context.core.savedObjects; + const { getClient, getImporter, typeRegistry } = context.core.savedObjects; + + const includedHiddenTypes = chain(req.body.retries) + .map('type') + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); + const importer = getImporter(client); try { const result = await importer.resolveImportErrors({ diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 4ad0a34acc2ef9..fce7f123844564 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -459,6 +459,7 @@ export class SavedObjectsService savedObjectsClient, typeRegistry: this.typeRegistry, exportSizeLimit: this.config!.maxImportExportSize, + logger: this.logger.get('exporter'), }), createImporter: (savedObjectsClient) => new SavedObjectsImporter({ diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index 79b9c2feb1cbb4..d53a53d745c0c8 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -20,6 +20,7 @@ const createRegistryMock = (): jest.Mocked< isNamespaceAgnostic: jest.fn(), isSingleNamespace: jest.fn(), isMultiNamespace: jest.fn(), + isShareable: jest.fn(), isHidden: jest.fn(), getIndex: jest.fn(), isImportableAndExportable: jest.fn(), @@ -36,6 +37,7 @@ const createRegistryMock = (): jest.Mocked< (type: string) => type !== 'global' && type !== 'shared' ); mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); + mock.isShareable.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); return mock; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index c0eb7891cd7d43..872b61706c526a 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -239,6 +239,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); @@ -263,6 +264,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); }); }); @@ -277,12 +279,36 @@ describe('SavedObjectTypeRegistry', () => { expect(registry.isMultiNamespace('unknownType')).toEqual(false); }); + it(`returns true for namespaceType 'multiple' and 'multiple-isolated'`, () => { + expectResult(true, { namespaceType: 'multiple' }); + expectResult(true, { namespaceType: 'multiple-isolated' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); + }); + }); + + describe('#isShareable', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isShareable('foo')).toBe(expected); + }; + + it(`returns false when the type is not registered`, () => { + expect(registry.isShareable('unknownType')).toEqual(false); + }); + it(`returns true for namespaceType 'multiple'`, () => { expectResult(true, { namespaceType: 'multiple' }); }); it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index 8a50beda83d2a0..a63837132b652e 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -86,10 +86,19 @@ export class SavedObjectTypeRegistry { } /** - * Returns whether the type is multi-namespace (shareable); + * Returns whether the type is multi-namespace (shareable *or* isolated); * resolves to `false` if the type is not registered */ public isMultiNamespace(type: string) { + const namespaceType = this.types.get(type)?.namespaceType; + return namespaceType === 'multiple' || namespaceType === 'multiple-isolated'; + } + + /** + * Returns whether the type is multi-namespace (shareable); + * resolves to `false` if the type is not registered + */ + public isShareable(type: string) { return this.types.get(type)?.namespaceType === 'multiple'; } diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts index f6f8f88e84304e..05a936db4bfee5 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -9,7 +9,12 @@ // @ts-expect-error no ts import { esKuery } from '../../es_query'; -import { validateFilterKueryNode, validateConvertFilterToKueryNode } from './filter_utils'; +import { + validateFilterKueryNode, + validateConvertFilterToKueryNode, + fieldDefined, + hasFilterKeyError, +} from './filter_utils'; const mockMappings = { properties: { @@ -39,6 +44,18 @@ const mockMappings = { }, }, }, + bean: { + properties: { + canned: { + fields: { + text: { + type: 'text', + }, + }, + type: 'keyword', + }, + }, + }, alert: { properties: { actions: { @@ -90,6 +107,15 @@ describe('Filter Utils', () => { validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockMappings) ).toEqual(esKuery.fromKueryExpression('foo.title: "best"')); }); + test('Validate a multi-field KQL expression filter', () => { + expect( + validateConvertFilterToKueryNode( + ['bean'], + 'bean.attributes.canned.text: "best"', + mockMappings + ) + ).toEqual(esKuery.fromKueryExpression('bean.canned.text: "best"')); + }); test('Assemble filter kuery node saved object attributes with one saved object type', () => { expect( validateConvertFilterToKueryNode( @@ -485,4 +511,100 @@ describe('Filter Utils', () => { ]); }); }); + + describe('#hasFilterKeyError', () => { + test('Return no error if filter key is valid', () => { + const hasError = hasFilterKeyError('bean.attributes.canned.text', ['bean'], mockMappings); + + expect(hasError).toBeNull(); + }); + + test('Return error if key is not defined', () => { + const hasError = hasFilterKeyError(undefined, ['bean'], mockMappings); + + expect(hasError).toEqual( + 'The key is empty and needs to be wrapped by a saved object type like bean' + ); + }); + + test('Return error if key is null', () => { + const hasError = hasFilterKeyError(null, ['bean'], mockMappings); + + expect(hasError).toEqual( + 'The key is empty and needs to be wrapped by a saved object type like bean' + ); + }); + + test('Return error if key does not identify an SO wrapper', () => { + const hasError = hasFilterKeyError('beanattributescannedtext', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'beanattributescannedtext' need to be wrapped by a saved object type like bean" + ); + }); + + test('Return error if key does not match an SO type', () => { + const hasError = hasFilterKeyError('canned.attributes.bean.text', ['bean'], mockMappings); + + expect(hasError).toEqual('This type canned is not allowed'); + }); + + test('Return error if key does not match SO attribute structure', () => { + const hasError = hasFilterKeyError('bean.canned.text', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'bean.canned.text' does NOT match the filter proposition SavedObjectType.attributes.key" + ); + }); + + test('Return error if key matches SO attribute parent, not attribute itself', () => { + const hasError = hasFilterKeyError('alert.actions', ['alert'], mockMappings); + + expect(hasError).toEqual( + "This key 'alert.actions' does NOT match the filter proposition SavedObjectType.attributes.key" + ); + }); + + test('Return error if key refers to a non-existent attribute parent', () => { + const hasError = hasFilterKeyError('alert.not_a_key', ['alert'], mockMappings); + + expect(hasError).toEqual( + "This key 'alert.not_a_key' does NOT exist in alert saved object index patterns" + ); + }); + + test('Return error if key refers to a non-existent attribute', () => { + const hasError = hasFilterKeyError('bean.attributes.red', ['bean'], mockMappings); + + expect(hasError).toEqual( + "This key 'bean.attributes.red' does NOT exist in bean saved object index patterns" + ); + }); + }); + + describe('#fieldDefined', () => { + test('Return false if filter is using an non-existing key', () => { + const isFieldDefined = fieldDefined(mockMappings, 'foo.not_a_key'); + + expect(isFieldDefined).toBeFalsy(); + }); + + test('Return true if filter is using an existing key', () => { + const isFieldDefined = fieldDefined(mockMappings, 'foo.title'); + + expect(isFieldDefined).toBeTruthy(); + }); + + test('Return true if filter is using a default for a multi-field property', () => { + const isFieldDefined = fieldDefined(mockMappings, 'bean.canned'); + + expect(isFieldDefined).toBeTruthy(); + }); + + test('Return true if filter is using a non-default for a multi-field property', () => { + const isFieldDefined = fieldDefined(mockMappings, 'bean.canned.text'); + + expect(isFieldDefined).toBeTruthy(); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index b81c7d3e0885a4..54b0033c9fcbe3 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -205,7 +205,25 @@ export const hasFilterKeyError = ( return null; }; -const fieldDefined = (indexMappings: IndexMapping, key: string) => { +export const fieldDefined = (indexMappings: IndexMapping, key: string): boolean => { const mappingKey = 'properties.' + key.split('.').join('.properties.'); - return get(indexMappings, mappingKey) != null; + const potentialKey = get(indexMappings, mappingKey); + + // If the `mappingKey` does not match a valid path, before returning null, + // we want to check and see if the intended path was for a multi-field + // such as `x.attributes.field.text` where `field` is mapped to both text + // and keyword + if (potentialKey == null) { + const propertiesAttribute = 'properties'; + const indexOfLastProperties = mappingKey.lastIndexOf(propertiesAttribute); + const fieldMapping = mappingKey.substr(0, indexOfLastProperties); + const fieldType = mappingKey.substr( + mappingKey.lastIndexOf(propertiesAttribute) + `${propertiesAttribute}.`.length + ); + const mapping = `${fieldMapping}fields.${fieldType}`; + + return get(indexMappings, mapping) != null; + } else { + return true; + } }; diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index c853e208f27aa3..a3610b1e437e24 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -17,6 +17,8 @@ const create = (): jest.Mocked => ({ bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + closePointInTime: jest.fn(), + openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index aac508fb5b909c..d26d92e84925a7 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -48,9 +48,29 @@ describe('SavedObjectsRepository', () => { const KIBANA_VERSION = '2.0.0'; const CUSTOM_INDEX_TYPE = 'customIndex'; + /** This type has namespaceType: 'agnostic'. */ const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; - const MULTI_NAMESPACE_TYPE = 'shareableType'; - const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'shareableTypeCustomIndex'; + /** + * This type has namespaceType: 'multiple'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is shareable across + * namespaces. + **/ + const MULTI_NAMESPACE_TYPE = 'multiNamespaceType'; + /** + * This type has namespaceType: 'multiple-isolated'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable + * across namespaces. This distinction only matters when using the `addToNamespaces` and `deleteFromNamespaces` APIs, or when using the + * `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what namespaces an object + * exists in. + * + * In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases + * where `MULTI_NAMESPACE_TYPE` would also satisfy the test case. + **/ + const MULTI_NAMESPACE_ISOLATED_TYPE = 'multiNamespaceIsolatedType'; + /** This type has namespaceType: 'multiple', and it uses a custom index. */ + const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'multiNamespaceTypeCustomIndex'; const HIDDEN_TYPE = 'hiddenType'; const mappings = { @@ -93,6 +113,13 @@ describe('SavedObjectsRepository', () => { }, }, }, + [MULTI_NAMESPACE_ISOLATED_TYPE]: { + properties: { + evenYetAnotherField: { + type: 'keyword', + }, + }, + }, [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { properties: { evenYetAnotherField: { @@ -132,6 +159,10 @@ describe('SavedObjectsRepository', () => { ...createType(MULTI_NAMESPACE_TYPE), namespaceType: 'multiple', }); + registry.registerType({ + ...createType(MULTI_NAMESPACE_ISOLATED_TYPE), + namespaceType: 'multiple-isolated', + }); registry.registerType({ ...createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE), namespaceType: 'multiple', @@ -345,13 +376,14 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [newNs1, newNs2], message); expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -518,11 +550,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget.mock.calls[0][0].body).toEqual({ docs }); }); @@ -601,7 +635,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); @@ -614,7 +648,7 @@ describe('SavedObjectsRepository', () => { it(`adds namespaces to request body for any types that are multi-namespace`, async () => { const test = async (namespace) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); const namespaces = [namespace ?? 'default']; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.objectContaining({ namespaces }); @@ -706,7 +740,7 @@ describe('SavedObjectsRepository', () => { const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); @@ -753,7 +787,7 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`returns error when initialNamespaces is used with a non-shareable object`, async () => { const test = async (objType) => { const obj = { ...obj3, type: objType, initialNamespaces: [] }; await bulkCreateError( @@ -767,9 +801,10 @@ describe('SavedObjectsRepository', () => { }; await test('dashboard'); await test(NAMESPACE_AGNOSTIC_TYPE); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; await bulkCreateError( obj, @@ -792,7 +827,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE }; + const obj = { ...obj3, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const response1 = { status: 200, docs: [ @@ -884,7 +919,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to body when not using single-namespace type`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespace: expect.anything() }, false, 1); @@ -892,14 +927,20 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespaces: [namespace] }, true, 1); expectMigrationArgs({ namespaces: [namespace] }, true, 2); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects); expectMigrationArgs({ namespaces: ['default'] }, true, 1); expectMigrationArgs({ namespaces: ['default'] }, true, 2); @@ -1070,7 +1111,7 @@ describe('SavedObjectsRepository', () => { _expectClientCallArgs(objects, { getId }); client.mget.mockClear(); - objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE })); await bulkGetSuccess(objects, { namespace }); _expectClientCallArgs(objects, { getId }); }); @@ -1130,7 +1171,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const response = getMockMgetResponse([obj1, obj, obj2]); response.docs[1].namespaces = ['bar-namespace']; await bulkGetErrorNotFound([obj1, obj, obj2], { namespace }, response); @@ -1189,7 +1230,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkGetSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1291,12 +1332,14 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkUpdateSuccess(objects); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget).toHaveBeenCalledWith( expect.objectContaining({ body: { docs } }), expect.anything() @@ -1313,7 +1356,7 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES request for any types that are multi-namespace`, async () => { - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([obj1, _obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; expect(client.bulk).toHaveBeenCalledWith( @@ -1384,8 +1427,8 @@ describe('SavedObjectsRepository', () => { it(`defaults to the version of the existing document for multi-namespace types`, async () => { // only multi-namespace documents are obtained using a pre-flight mget request const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkUpdateSuccess(objects); const overrides = { @@ -1406,7 +1449,7 @@ describe('SavedObjectsRepository', () => { // test with both non-multi-namespace and multi-namespace types const objects = [ { ...obj1, version }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, version }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, version }, ]; await bulkUpdateSuccess(objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; @@ -1459,7 +1502,7 @@ describe('SavedObjectsRepository', () => { if_seq_no: expect.any(Number), }; const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([_obj1], { namespace }); expectClientCallArgsAction([_obj1], { method: 'update', getId }); @@ -1558,19 +1601,19 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when ES is unable to find the document (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; const mgetResponse = getMockMgetResponse([_obj]); await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); }); it(`returns error when ES is unable to find the index (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = { statusCode: 404 }; await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = getMockMgetResponse([_obj], 'bar-namespace'); await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); @@ -1643,7 +1686,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1654,7 +1697,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes originId property if present in cluster call response`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj], {}, true); expect(result).toEqual({ saved_objects: [ @@ -1669,9 +1712,9 @@ describe('SavedObjectsRepository', () => { describe('#checkConflicts', () => { const obj1 = { type: 'dashboard', id: 'one' }; const obj2 = { type: 'dashboard', id: 'two' }; - const obj3 = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; - const obj4 = { type: MULTI_NAMESPACE_TYPE, id: 'four' }; - const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; + const obj3 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; + const obj4 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'four' }; + const obj5 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'five' }; const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; const namespace = 'foo-namespace'; @@ -1854,7 +1897,7 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true }); expect(client.get).toHaveBeenCalled(); expect(client.index).toHaveBeenCalled(); }); @@ -1975,10 +2018,10 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id and adds namespaces to body when using multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, body: expect.objectContaining({ namespaces: [namespace] }), }), expect.anything() @@ -2013,7 +2056,7 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`throws when options.initialNamespaces is used with a non-shareable object`, async () => { const test = async (objType) => { await expect( savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) @@ -2024,10 +2067,11 @@ describe('SavedObjectsRepository', () => { ); }; await test('dashboard'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( @@ -2056,17 +2100,20 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true, namespace, }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalled(); }); @@ -2105,17 +2152,17 @@ describe('SavedObjectsRepository', () => { expectMigrationArgs({ namespace: expect.anything() }, false, 1); client.create.mockClear(); - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expectMigrationArgs({ namespaces: [namespace] }); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespaces: ['default'] }); }); @@ -2181,13 +2228,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use ES get action then delete action when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledTimes(1); }); it(`includes the version of the existing document when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -2238,9 +2285,9 @@ describe('SavedObjectsRepository', () => { ); client.delete.mockClear(); - await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }), + expect.objectContaining({ id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}` }), expect.anything() ); }); @@ -2273,7 +2320,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -2281,27 +2328,29 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = [namespace, 'bar-namespace']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -2309,13 +2358,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = ['*']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -2813,6 +2862,13 @@ describe('SavedObjectsRepository', () => { expect(client.search).not.toHaveBeenCalled(); }); + it(`throws when a preference is provided with pit`, async () => { + await expect( + savedObjectsRepository.find({ type: 'foo', pit: { id: 'abc123' }, preference: 'hi' }) + ).rejects.toThrowError('options.preference must be excluded when options.pit is used'); + expect(client.search).not.toHaveBeenCalled(); + }); + it(`throws when KQL filter syntax is invalid`, async () => { const findOpts = { namespaces: [namespace], @@ -2973,6 +3029,32 @@ describe('SavedObjectsRepository', () => { }); }); + it(`accepts searchAfter`, async () => { + const relevantOpts = { + ...commonOptions, + searchAfter: [1, 'a'], + }; + + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + searchAfter: [1, 'a'], + }); + }); + + it(`accepts pit`, async () => { + const relevantOpts = { + ...commonOptions, + pit: { id: 'abc123', keepAlive: '2m' }, + }; + + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + pit: { id: 'abc123', keepAlive: '2m' }, + }); + }); + it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { namespaces: [namespace], @@ -3167,10 +3249,10 @@ describe('SavedObjectsRepository', () => { ); client.get.mockClear(); - await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3217,11 +3299,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -3243,7 +3327,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await getSuccess(MULTI_NAMESPACE_TYPE, id); + const result = await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(result).toMatchObject({ namespaces: expect.any(Array), }); @@ -3418,8 +3502,12 @@ describe('SavedObjectsRepository', () => { it('but alias target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id }, // correct namespace field is added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, // correct namespace field is added by getMockMgetResponse + { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: aliasTargetId, + namespace: `not-${namespace}`, + }, // overrides namespace field that would otherwise be added by getMockMgetResponse ]; await expectExactMatchResult(objects); }); @@ -3442,6 +3530,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id: aliasTargetId }), outcome: 'aliasMatch', + aliasTargetId, }); }; @@ -3455,8 +3544,8 @@ describe('SavedObjectsRepository', () => { it('because actual target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse ]; await expectAliasMatchResult(objects); }); @@ -3482,6 +3571,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'conflict', + aliasTargetId, }); }); }); @@ -3537,7 +3627,9 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -3592,10 +3684,12 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3660,15 +3754,23 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, counterFields, { - namespace, - }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + savedObjectsRepository.incrementCounter( + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + counterFields, + { + namespace, + } + ) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -3976,7 +4078,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [namespace1, namespace2], message); @@ -3984,6 +4086,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -4148,7 +4251,7 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES get action then update action when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -4212,7 +4315,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to the version of the existing document when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { references }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { references }); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -4267,15 +4370,17 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { namespace }); expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }), + expect.objectContaining({ + id: expect.stringMatching(`${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`), + }), expect.anything() ); }); it(`includes _source_includes when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), expect.anything() @@ -4320,7 +4425,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4328,16 +4433,18 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4374,7 +4481,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + const result = await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(result).toMatchObject({ namespaces: expect.any(Array), }); @@ -4393,4 +4500,136 @@ describe('SavedObjectsRepository', () => { }); }); }); + + describe('#openPointInTimeForType', () => { + const type = 'index-pattern'; + + const generateResults = (id) => ({ id: id || null }); + const successResponse = async (type, options) => { + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(generateResults()) + ); + const result = await savedObjectsRepository.openPointInTimeForType(type, options); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use the ES PIT API`, async () => { + await successResponse(type); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + }); + + it(`accepts preference`, async () => { + await successResponse(type, { preference: 'pref' }); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + preference: 'pref', + }), + expect.anything() + ); + }); + + it(`accepts keepAlive`, async () => { + await successResponse(type, { keepAlive: '2m' }); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + keep_alive: '2m', + }), + expect.anything() + ); + }); + + it(`defaults keepAlive to 5m`, async () => { + await successResponse(type); + expect(client.openPointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + keep_alive: '5m', + }), + expect.anything() + ); + }); + }); + + describe('errors', () => { + const expectNotFoundError = async (types) => { + await expect(savedObjectsRepository.openPointInTimeForType(types)).rejects.toThrowError( + createGenericNotFoundError() + ); + }; + + it(`throws when ES is unable to find the index`, async () => { + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundError(type); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + }); + + it(`should return generic not found error when attempting to find only invalid or hidden types`, async () => { + const test = async (types) => { + await expectNotFoundError(types); + expect(client.openPointInTime).not.toHaveBeenCalled(); + }; + + await test('unknownType'); + await test(HIDDEN_TYPE); + await test(['unknownType', HIDDEN_TYPE]); + }); + }); + + describe('returns', () => { + it(`returns id in the expected format`, async () => { + const id = 'abc123'; + const results = generateResults(id); + client.openPointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(results) + ); + const response = await savedObjectsRepository.openPointInTimeForType(type); + expect(response).toEqual({ id }); + }); + }); + }); + + describe('#closePointInTime', () => { + const generateResults = () => ({ succeeded: true, num_freed: 3 }); + const successResponse = async (id) => { + client.closePointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(generateResults()) + ); + const result = await savedObjectsRepository.closePointInTime(id); + expect(client.closePointInTime).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use the ES PIT API`, async () => { + await successResponse('abc123'); + expect(client.closePointInTime).toHaveBeenCalledTimes(1); + }); + + it(`accepts id`, async () => { + await successResponse('abc123'); + expect(client.closePointInTime).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + id: 'abc123', + }), + }), + expect.anything() + ); + }); + }); + + describe('returns', () => { + it(`returns response body from ES`, async () => { + const results = generateResults('abc123'); + client.closePointInTime.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(results) + ); + const response = await savedObjectsRepository.closePointInTime('abc123'); + expect(response).toEqual(results); + }); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index fcd72aa4326a20..78c3cdcb91e029 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -36,6 +36,10 @@ import { SavedObjectsCreateOptions, SavedObjectsFindResponse, SavedObjectsFindResult, + SavedObjectsClosePointInTimeOptions, + SavedObjectsClosePointInTimeResponse, + SavedObjectsOpenPointInTimeOptions, + SavedObjectsOpenPointInTimeResponse, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, SavedObjectsBulkUpdateObject, @@ -247,7 +251,7 @@ export class SavedObjectsRepository { const namespace = normalizeNamespace(options.namespace); if (initialNamespaces) { - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( '"options.initialNamespaces" can only be used on multi-namespace types' ); @@ -336,7 +340,7 @@ export class SavedObjectsRepository { if (!this._allowedTypes.includes(object.type)) { error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); } else if (object.initialNamespaces) { - if (!this._registry.isMultiNamespace(object.type)) { + if (!this._registry.isShareable(object.type)) { error = SavedObjectsErrorHelpers.createBadRequestError( '"initialNamespaces" can only be used on multi-namespace types' ); @@ -708,11 +712,13 @@ export class SavedObjectsRepository { * Query field argument for more information * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] + * @property {Array} [options.searchAfter] * @property {string} [options.sortField] * @property {string} [options.sortOrder] * @property {Array} [options.fields] * @property {string} [options.namespace] * @property {object} [options.hasReference] - { type, id } + * @property {string} [options.pit] * @property {string} [options.preference] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ @@ -726,6 +732,8 @@ export class SavedObjectsRepository { hasReferenceOperator, page = FIND_DEFAULT_PAGE, perPage = FIND_DEFAULT_PER_PAGE, + pit, + searchAfter, sortField, sortOrder, fields, @@ -752,6 +760,10 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createBadRequestError( 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' ); + } else if (preference?.length && pit) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.preference must be excluded when options.pit is used' + ); } const types = type @@ -787,20 +799,24 @@ export class SavedObjectsRepository { } const esOptions = { - index: this.getIndicesForTypes(allowedTypes), - size: perPage, - from: perPage * (page - 1), + // If `pit` is provided, we drop the `index`, otherwise ES returns 400. + ...(pit ? {} : { index: this.getIndicesForTypes(allowedTypes) }), + // If `searchAfter` is provided, we drop `from` as it will not be used for pagination. + ...(searchAfter ? {} : { from: perPage * (page - 1) }), _source: includedFields(type, fields), - rest_total_hits_as_int: true, preference, + rest_total_hits_as_int: true, + size: perPage, body: { seq_no_primary_term: true, ...getSearchDsl(this._mappings, this._registry, { search, defaultSearchOperator, searchFields, + pit, rootSearchFields, type: allowedTypes, + searchAfter, sortField, sortOrder, namespaces, @@ -834,8 +850,10 @@ export class SavedObjectsRepository { (hit: SavedObjectsRawDoc): SavedObjectsFindResult => ({ ...this._rawToSavedObject(hit), score: (hit as any)._score, + ...((hit as any).sort && { sort: (hit as any).sort }), }) ), + ...(body.pit_id && { pit_id: body.pit_id }), } as SavedObjectsFindResponse; } @@ -1067,6 +1085,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), outcome: 'conflict', + aliasTargetId: legacyUrlAlias.targetId, }; } else if (foundExactMatch) { return { @@ -1077,6 +1096,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), outcome: 'aliasMatch', + aliasTargetId: legacyUrlAlias.targetId, }; } throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -1176,7 +1196,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); @@ -1239,7 +1259,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); @@ -1764,6 +1784,118 @@ export class SavedObjectsRepository { }; } + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + * + * @example + * ```ts + * const { id } = await savedObjectsClient.openPointInTimeForType( + * type: 'visualization', + * { keepAlive: '5m' }, + * ); + * const page1 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id, keepAlive: '2m' }, + * }); + * const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; + * const page2 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id: page1.pit_id }, + * searchAfter: lastHit.sort, + * }); + * await savedObjectsClient.closePointInTime(page2.pit_id); + * ``` + * + * @param {string|Array} type + * @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions} + * @property {string} [options.keepAlive] + * @property {string} [options.preference] + * @returns {promise} - { id: string } + */ + async openPointInTimeForType( + type: string | string[], + { keepAlive = '5m', preference }: SavedObjectsOpenPointInTimeOptions = {} + ): Promise { + const types = Array.isArray(type) ? type : [type]; + const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); + if (allowedTypes.length === 0) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } + + const esOptions = { + index: this.getIndicesForTypes(allowedTypes), + keep_alive: keepAlive, + ...(preference ? { preference } : {}), + }; + + const { + body, + statusCode, + } = await this.client.openPointInTime(esOptions, { + ignore: [404], + }); + if (statusCode === 404) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } + + return { + id: body.id, + }; + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES + * via the Elasticsearch client, and is included in the Saved Objects Client + * as a convenience for consumers who are using `openPointInTimeForType`. + * + * @remarks + * While the `keepAlive` that is provided will cause a PIT to automatically close, + * it is highly recommended to explicitly close a PIT when you are done with it + * in order to avoid consuming unneeded resources in Elasticsearch. + * + * @example + * ```ts + * const repository = coreStart.savedObjects.createInternalRepository(); + * + * const { id } = await repository.openPointInTimeForType( + * type: 'index-pattern', + * { keepAlive: '2m' }, + * ); + * + * const response = await repository.find({ + * type: 'index-pattern', + * search: 'foo*', + * sortField: 'name', + * sortOrder: 'desc', + * pit: { + * id: 'abc123', + * keepAlive: '2m', + * }, + * searchAfter: [1234, 'abcd'], + * }); + * + * await repository.closePointInTime(response.pit_id); + * ``` + * + * @param {string} id + * @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions} + * @returns {promise} - {@link SavedObjectsClosePointInTimeResponse} + */ + async closePointInTime( + id: string, + options?: SavedObjectsClosePointInTimeOptions + ): Promise { + const { body } = await this.client.closePointInTime({ + body: { id }, + }); + return body; + } + /** * Returns index specified by the given type or the default index * diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.ts b/src/core/server/saved_objects/service/lib/repository_es_client.ts index dae72819726ad2..6a601b1ed0c839 100644 --- a/src/core/server/saved_objects/service/lib/repository_es_client.ts +++ b/src/core/server/saved_objects/service/lib/repository_es_client.ts @@ -14,11 +14,13 @@ import { decorateEsError } from './decorate_es_error'; const methods = [ 'bulk', + 'closePointInTime', 'create', 'delete', 'get', 'index', 'mget', + 'openPointInTime', 'search', 'update', 'updateByQuery', diff --git a/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts new file mode 100644 index 00000000000000..5a99168792e83b --- /dev/null +++ b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getPitParams } from './pit_params'; + +describe('searchDsl/getPitParams', () => { + it('returns only an ID by default', () => { + expect(getPitParams({ id: 'abc123' })).toEqual({ + pit: { + id: 'abc123', + }, + }); + }); + + it('includes keepAlive if provided and rewrites to snake case', () => { + expect(getPitParams({ id: 'abc123', keepAlive: '2m' })).toEqual({ + pit: { + id: 'abc123', + keep_alive: '2m', + }, + }); + }); +}); diff --git a/src/fixtures/mock_state.js b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts similarity index 60% rename from src/fixtures/mock_state.js rename to src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts index cb18dac7b767d0..1a8dcb5cca2e9d 100644 --- a/src/fixtures/mock_state.js +++ b/src/core/server/saved_objects/service/lib/search_dsl/pit_params.ts @@ -6,15 +6,13 @@ * Side Public License, v 1. */ -import _ from 'lodash'; -import sinon from 'sinon'; +import { SavedObjectsPitParams } from '../../../types'; -function MockState(defaults) { - this.on = _.noop; - this.off = _.noop; - this.save = sinon.stub(); - this.replace = sinon.stub(); - _.assign(this, defaults); +export function getPitParams(pit: SavedObjectsPitParams) { + return { + pit: { + id: pit.id, + ...(pit.keepAlive ? { keep_alive: pit.keepAlive } : {}), + }, + }; } - -export default MockState; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 9e91e585f74f04..fc26c837d5e52f 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -6,14 +6,17 @@ * Side Public License, v 1. */ +jest.mock('./pit_params'); jest.mock('./query_params'); jest.mock('./sorting_params'); import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; +import * as pitParamsNS from './pit_params'; import * as queryParamsNS from './query_params'; import { getSearchDsl } from './search_dsl'; import * as sortParamsNS from './sorting_params'; +const getPitParams = pitParamsNS.getPitParams as jest.Mock; const getQueryParams = queryParamsNS.getQueryParams as jest.Mock; const getSortingParams = sortParamsNS.getSortingParams as jest.Mock; @@ -84,6 +87,7 @@ describe('getSearchDsl', () => { type: 'foo', sortField: 'bar', sortOrder: 'baz', + pit: { id: 'abc123' }, }; getSearchDsl(mappings, registry, opts); @@ -92,7 +96,8 @@ describe('getSearchDsl', () => { mappings, opts.type, opts.sortField, - opts.sortOrder + opts.sortOrder, + opts.pit ); }); @@ -101,5 +106,33 @@ describe('getSearchDsl', () => { getSortingParams.mockReturnValue({ b: 'b' }); expect(getSearchDsl(mappings, registry, { type: 'foo' })).toEqual({ a: 'a', b: 'b' }); }); + + it('returns searchAfter if provided', () => { + getQueryParams.mockReturnValue({ a: 'a' }); + getSortingParams.mockReturnValue({ b: 'b' }); + expect(getSearchDsl(mappings, registry, { type: 'foo', searchAfter: [1, 'bar'] })).toEqual({ + a: 'a', + b: 'b', + search_after: [1, 'bar'], + }); + }); + + it('returns pit if provided', () => { + getQueryParams.mockReturnValue({ a: 'a' }); + getSortingParams.mockReturnValue({ b: 'b' }); + getPitParams.mockReturnValue({ pit: { id: 'abc123' } }); + expect( + getSearchDsl(mappings, registry, { + type: 'foo', + searchAfter: [1, 'bar'], + pit: { id: 'abc123' }, + }) + ).toEqual({ + a: 'a', + b: 'b', + pit: { id: 'abc123' }, + search_after: [1, 'bar'], + }); + }); }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 4b4fa8865ee9d8..cae5e43897bcfe 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -9,7 +9,9 @@ import Boom from '@hapi/boom'; import { IndexMapping } from '../../../mappings'; +import { SavedObjectsPitParams } from '../../../types'; import { getQueryParams, HasReferenceQueryParams, SearchOperator } from './query_params'; +import { getPitParams } from './pit_params'; import { getSortingParams } from './sorting_params'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; @@ -21,9 +23,11 @@ interface GetSearchDslOptions { defaultSearchOperator?: SearchOperator; searchFields?: string[]; rootSearchFields?: string[]; + searchAfter?: unknown[]; sortField?: string; sortOrder?: string; namespaces?: string[]; + pit?: SavedObjectsPitParams; typeToNamespacesMap?: Map; hasReference?: HasReferenceQueryParams | HasReferenceQueryParams[]; hasReferenceOperator?: SearchOperator; @@ -41,9 +45,11 @@ export function getSearchDsl( defaultSearchOperator, searchFields, rootSearchFields, + searchAfter, sortField, sortOrder, namespaces, + pit, typeToNamespacesMap, hasReference, hasReferenceOperator, @@ -72,6 +78,8 @@ export function getSearchDsl( hasReferenceOperator, kueryNode, }), - ...getSortingParams(mappings, type, sortField, sortOrder), + ...getSortingParams(mappings, type, sortField, sortOrder, pit), + ...(pit ? getPitParams(pit) : {}), + ...(searchAfter ? { search_after: searchAfter } : {}), }; } diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts index 1376f0d50a9da6..73c7065705fc54 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts @@ -79,6 +79,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect(getSortingParams(MAPPINGS, 'saved', 'title', undefined, { id: 'abc' }).sort).toEqual( + expect.arrayContaining([{ _shard_doc: 'asc' }]) + ); + }); }); describe('sortField is simple root property with multiple types', () => { it('returns correct params', () => { @@ -93,6 +98,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved', 'pending'], 'type', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is simple non-root property with multiple types', () => { it('returns correct params', () => { @@ -114,6 +124,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, 'saved', 'title.raw', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is multi-field with single type as array', () => { it('returns correct params', () => { @@ -128,6 +143,11 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved'], 'title.raw', undefined, { id: 'abc' }).sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is root multi-field with multiple types', () => { it('returns correct params', () => { @@ -142,6 +162,12 @@ describe('searchDsl/getSortParams', () => { ], }); }); + it('appends tiebreaker when PIT is provided', () => { + expect( + getSortingParams(MAPPINGS, ['saved', 'pending'], 'type.raw', undefined, { id: 'abc' }) + .sort + ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); + }); }); describe('sortField is not-root multi-field with multiple types', () => { it('returns correct params', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index e3bfba6a80f59d..abef9bfa0a3006 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -8,6 +8,12 @@ import Boom from '@hapi/boom'; import { getProperty, IndexMapping } from '../../../mappings'; +import { SavedObjectsPitParams } from '../../../types'; + +// TODO: The plan is for ES to automatically add this tiebreaker when +// using PIT. We should remove this logic once that is resolved. +// https://github.com/elastic/elasticsearch/issues/56828 +const ES_PROVIDED_TIEBREAKER = { _shard_doc: 'asc' }; const TOP_LEVEL_FIELDS = ['_id', '_score']; @@ -15,7 +21,8 @@ export function getSortingParams( mappings: IndexMapping, type: string | string[], sortField?: string, - sortOrder?: string + sortOrder?: string, + pit?: SavedObjectsPitParams ) { if (!sortField) { return {}; @@ -31,6 +38,7 @@ export function getSortingParams( order: sortOrder, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -51,6 +59,7 @@ export function getSortingParams( unmapped_type: rootField.type, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -75,6 +84,7 @@ export function getSortingParams( unmapped_type: field.type, }, }, + ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index 72f5561aa70279..ecca652cace37f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -20,6 +20,8 @@ const create = () => bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + closePointInTime: jest.fn(), + openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 45b0cf70b0dc6e..7cbddaf195dc93 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -115,6 +115,36 @@ test(`#get`, async () => { expect(result).toBe(returnValue); }); +test(`#openPointInTimeForType`, async () => { + const returnValue = Symbol(); + const mockRepository = { + openPointInTimeForType: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const options = Symbol(); + const result = await client.openPointInTimeForType(type, options); + + expect(mockRepository.openPointInTimeForType).toHaveBeenCalledWith(type, options); + expect(result).toBe(returnValue); +}); + +test(`#closePointInTime`, async () => { + const returnValue = Symbol(); + const mockRepository = { + closePointInTime: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const id = Symbol(); + const options = Symbol(); + const result = await client.closePointInTime(id, options); + + expect(mockRepository.closePointInTime).toHaveBeenCalledWith(id, options); + expect(result).toBe(returnValue); +}); + test(`#resolve`, async () => { const returnValue = Symbol(); const mockRepository = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index b90540fbfa9716..b078f3eef018cd 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -129,6 +129,35 @@ export interface SavedObjectsFindResult extends SavedObject { * The Elasticsearch `_score` of this result. */ score: number; + /** + * The Elasticsearch `sort` value of this result. + * + * @remarks + * This can be passed directly to the `searchAfter` param in the {@link SavedObjectsFindOptions} + * in order to page through large numbers of hits. It is recommended you use this alongside + * a Point In Time (PIT) that was opened with {@link SavedObjectsClient.openPointInTimeForType}. + * + * @example + * ```ts + * const { id } = await savedObjectsClient.openPointInTimeForType('visualization'); + * const page1 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id }, + * }); + * const lastHit = page1.saved_objects[page1.saved_objects.length - 1]; + * const page2 = await savedObjectsClient.find({ + * type: 'visualization', + * sortField: 'updated_at', + * sortOrder: 'asc', + * pit: { id: page1.pit_id }, + * searchAfter: lastHit.sort, + * }); + * await savedObjectsClient.closePointInTime(page2.pit_id); + * ``` + */ + sort?: unknown[]; } /** @@ -144,6 +173,7 @@ export interface SavedObjectsFindResponse { total: number; per_page: number; page: number; + pit_id?: string; } /** @@ -309,6 +339,54 @@ export interface SavedObjectsResolveResponse { * `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. */ outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + /** + * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + */ + aliasTargetId?: string; +} + +/** + * @public + */ +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { + /** + * Optionally specify how long ES should keep the PIT alive until the next request. Defaults to `5m`. + */ + keepAlive?: string; + /** + * An optional ES preference value to be used for the query. + */ + preference?: string; +} + +/** + * @public + */ +export interface SavedObjectsOpenPointInTimeResponse { + /** + * PIT ID returned from ES. + */ + id: string; +} + +/** + * @public + */ +export type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; + +/** + * @public + */ +export interface SavedObjectsClosePointInTimeResponse { + /** + * If true, all search contexts associated with the PIT id are + * successfully closed. + */ + succeeded: boolean; + /** + * The number of search contexts that have been successfully closed. + */ + num_freed: number; } /** @@ -504,4 +582,25 @@ export class SavedObjectsClient { ) { return await this._repository.removeReferencesTo(type, id, options); } + + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to {@link SavedObjectsClient.find} to search + * against that PIT. + */ + async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions = {} + ) { + return await this._repository.openPointInTimeForType(type, options); + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the + * Elasticsearch client, and is included in the Saved Objects Client as a convenience + * for consumers who are using {@link SavedObjectsClient.openPointInTimeForType}. + */ + async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { + return await this._repository.closePointInTime(id, options); + } } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index d122e92aba3989..57a77a9ebc5257 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -62,6 +62,14 @@ export interface SavedObjectsFindOptionsReference { id: string; } +/** + * @public + */ +export interface SavedObjectsPitParams { + id: string; + keepAlive?: string; +} + /** * * @public @@ -82,6 +90,10 @@ export interface SavedObjectsFindOptions { search?: string; /** The fields to perform the parsed query against. See Elasticsearch Simple Query String `fields` argument for more information */ searchFields?: string[]; + /** + * Use the sort values from the previous page to retrieve the next page of results. + */ + searchAfter?: unknown[]; /** * The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not * be modified. If used in conjunction with `searchFields`, both are concatenated together. @@ -114,6 +126,10 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; /** An optional ES preference value to be used for the query **/ preference?: string; + /** + * Search against a specific Point In Time (PIT) that you've opened with {@link SavedObjectsClient.openPointInTimeForType}. + */ + pit?: SavedObjectsPitParams; } /** @@ -197,13 +213,17 @@ export type SavedObjectsClientContract = Pick SavedObjectMigrationMap); /** - * If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. + * If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this + * version. * * Requirements: * * 1. This string value must be a valid semver version * 2. This type must have previously specified {@link SavedObjectsNamespaceType | `namespaceType: 'single'`} - * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} + * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} *or* + * {@link SavedObjectsNamespaceType | `namespaceType: 'multiple-isolated'`} * - * Example of a single-namespace type in 7.10: + * Example of a single-namespace type in 7.12: * * ```ts * { @@ -262,7 +284,19 @@ export interface SavedObjectsType { * } * ``` * - * Example after converting to a multi-namespace type in 7.11: + * Example after converting to a multi-namespace (isolated) type in 8.0: + * + * ```ts + * { + * name: 'foo', + * hidden: false, + * namespaceType: 'multiple-isolated', + * mappings: {...}, + * convertToMultiNamespaceTypeVersion: '8.0.0' + * } + * ``` + * + * Example after converting to a multi-namespace (shareable) type in 8.1: * * ```ts * { @@ -270,11 +304,11 @@ export interface SavedObjectsType { * hidden: false, * namespaceType: 'multiple', * mappings: {...}, - * convertToMultiNamespaceTypeVersion: '7.11.0' + * convertToMultiNamespaceTypeVersion: '8.0.0' * } * ``` * - * Note: a migration function can be optionally specified for the same version. + * Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. */ convertToMultiNamespaceTypeVersion?: string; /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 09207608908a45..377cd2bc2068a9 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -449,6 +449,7 @@ export interface CoreConfigUsageData { }; // (undocumented) savedObjects: { + customIndex: boolean; maxImportPayloadBytes: number; maxImportExportSizeBytes: number; }; @@ -1923,8 +1924,9 @@ export interface RequestHandlerContext { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; @@ -2093,7 +2095,9 @@ export interface SavedObjectExportBaseOptions { // @public export interface SavedObjectMigrationContext { + convertToMultiNamespaceTypeVersion?: string; log: SavedObjectsMigrationLogger; + migrationVersion: string; } // @public @@ -2222,6 +2226,7 @@ export class SavedObjectsClient { bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; + closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; @@ -2231,6 +2236,7 @@ export class SavedObjectsClient { errors: typeof SavedObjectsErrorHelpers; find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; + openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; @@ -2269,6 +2275,15 @@ export interface SavedObjectsClientWrapperOptions { typeRegistry: ISavedObjectTypeRegistry; } +// @public (undocumented) +export type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; + +// @public (undocumented) +export interface SavedObjectsClosePointInTimeResponse { + num_freed: number; + succeeded: boolean; +} + // @public export interface SavedObjectsComplexFieldMapping { // (undocumented) @@ -2419,10 +2434,11 @@ export interface SavedObjectsExportByTypeOptions extends SavedObjectExportBaseOp export class SavedObjectsExporter { // (undocumented) #private; - constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, }: { + constructor({ savedObjectsClient, typeRegistry, exportSizeLimit, logger, }: { savedObjectsClient: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; exportSizeLimit: number; + logger: Logger; }); exportByObjects(options: SavedObjectsExportByObjectOptions): Promise; exportByTypes(options: SavedObjectsExportByTypeOptions): Promise; @@ -2480,9 +2496,11 @@ export interface SavedObjectsFindOptions { page?: number; // (undocumented) perPage?: number; + pit?: SavedObjectsPitParams; preference?: string; rootSearchFields?: string[]; search?: string; + searchAfter?: unknown[]; searchFields?: string[]; // (undocumented) sortField?: string; @@ -2508,6 +2526,8 @@ export interface SavedObjectsFindResponse { // (undocumented) per_page: number; // (undocumented) + pit_id?: string; + // (undocumented) saved_objects: Array>; // (undocumented) total: number; @@ -2516,6 +2536,7 @@ export interface SavedObjectsFindResponse { // @public (undocumented) export interface SavedObjectsFindResult extends SavedObject { score: number; + sort?: unknown[]; } // @public @@ -2740,7 +2761,26 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; + +// @public (undocumented) +export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { + keepAlive?: string; + preference?: string; +} + +// @public (undocumented) +export interface SavedObjectsOpenPointInTimeResponse { + id: string; +} + +// @public (undocumented) +export interface SavedObjectsPitParams { + // (undocumented) + id: string; + // (undocumented) + keepAlive?: string; +} // @public export interface SavedObjectsRawDoc { @@ -2778,6 +2818,7 @@ export class SavedObjectsRepository { bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; + closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; // Warning: (ae-forgotten-export) The symbol "IKibanaMigrator" needs to be exported by the entry point index.d.ts // @@ -2790,6 +2831,7 @@ export class SavedObjectsRepository { find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFields: Array, options?: SavedObjectsIncrementCounterOptions): Promise>; + openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise; removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; @@ -2811,6 +2853,7 @@ export interface SavedObjectsResolveImportErrorsOptions { // @public (undocumented) export interface SavedObjectsResolveResponse { + aliasTargetId?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; // (undocumented) saved_object: SavedObject; @@ -2924,6 +2967,7 @@ export class SavedObjectTypeRegistry { isImportableAndExportable(type: string): boolean; isMultiNamespace(type: string): boolean; isNamespaceAgnostic(type: string): boolean; + isShareable(type: string): boolean; isSingleNamespace(type: string): boolean; registerType(type: SavedObjectsType): void; } @@ -2954,10 +2998,12 @@ export interface SearchResponse { highlight?: any; inner_hits?: any; matched_queries?: string[]; - sort?: string[]; + sort?: unknown[]; }>; }; // (undocumented) + pit_id?: string; + // (undocumented) _scroll_id?: string; // (undocumented) _shards: ShardsResponse; @@ -3112,6 +3158,7 @@ export interface UiSettingsParams { name?: string; optionLabels?: Record; options?: string[]; + order?: number; readonly?: boolean; requiresPageReload?: boolean; // (undocumented) @@ -3134,7 +3181,7 @@ export interface UiSettingsServiceStart { } // @public -export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image'; +export type UiSettingsType = 'undefined' | 'json' | 'markdown' | 'number' | 'select' | 'boolean' | 'string' | 'array' | 'image' | 'color'; // @public export interface UserProvidedValues { diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 1839ee68190aa5..2ae51d4452a4e6 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -31,6 +31,7 @@ export type { SavedObjectStatusMeta, SavedObjectsFindOptionsReference, SavedObjectsFindOptions, + SavedObjectsPitParams, SavedObjectsBaseOptions, MutatingOperationRefreshSetting, SavedObjectsClientContract, diff --git a/src/core/types/ui_settings.ts b/src/core/types/ui_settings.ts index e50dc18d9ff1fe..235553293d1537 100644 --- a/src/core/types/ui_settings.ts +++ b/src/core/types/ui_settings.ts @@ -22,7 +22,8 @@ export type UiSettingsType = | 'boolean' | 'string' | 'array' - | 'image'; + | 'image' + | 'color'; /** * UiSettings deprecation field options. @@ -65,6 +66,13 @@ export interface UiSettingsParams { type?: UiSettingsType; /** optional deprecation information. Used to generate a deprecation warning. */ deprecation?: DeprecationSettings; + /** + * index of the settings within its category (ascending order, smallest will be displayed first). + * Used for ordering in the UI. + * + * @remark settings without order defined will be displayed last and ordered by name + */ + order?: number; /* * Allows defining a custom validation applicable to value change on the client. * @deprecated diff --git a/src/dev/build/lib/integration_tests/version_info.test.ts b/src/dev/build/lib/integration_tests/version_info.test.ts index 34d537611e0c6f..e7a3a04c047345 100644 --- a/src/dev/build/lib/integration_tests/version_info.test.ts +++ b/src/dev/build/lib/integration_tests/version_info.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { kibanaPackageJSON as pkg } from '@kbn/dev-utils'; +import { kibanaPackageJson as pkg } from '@kbn/dev-utils'; import { getVersionInfo } from '../version_info'; diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index e18460d65a3d07..e37a61582c6a85 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -54,15 +54,13 @@ export const CreateDockerCentOS: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { - ubi: false, - context: false, architecture: 'x64', + context: false, image: true, }); await runDockerGenerator(config, log, build, { - ubi: false, - context: false, architecture: 'aarch64', + context: false, image: true, }); }, @@ -74,9 +72,9 @@ export const CreateDockerUBI: Task = { async run(config, log, build) { if (!build.isOss()) { await runDockerGenerator(config, log, build, { - ubi: true, - context: false, architecture: 'x64', + context: false, + ubi: true, image: true, }); } @@ -88,7 +86,6 @@ export const CreateDockerContexts: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { - ubi: false, context: true, image: false, }); @@ -99,6 +96,11 @@ export const CreateDockerContexts: Task = { context: true, image: false, }); + await runDockerGenerator(config, log, build, { + ironbank: true, + context: true, + image: false, + }); } }, }; diff --git a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts index 7eeeaebe6e4bed..a633e919cc5db2 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts @@ -7,18 +7,18 @@ */ import { resolve } from 'path'; +import { readFileSync } from 'fs'; import { ToolingLog } from '@kbn/dev-utils'; +import Mustache from 'mustache'; import { compressTar, copyAll, mkdirp, write, Config } from '../../../lib'; import { dockerfileTemplate } from './templates'; import { TemplateContext } from './template_context'; export async function bundleDockerFiles(config: Config, log: ToolingLog, scope: TemplateContext) { - log.info( - `Generating kibana${scope.imageFlavor}${scope.ubiImageFlavor} docker build context bundle` - ); - const dockerFilesDirName = `kibana${scope.imageFlavor}${scope.ubiImageFlavor}-${scope.version}-docker-build-context`; + log.info(`Generating kibana${scope.imageFlavor} docker build context bundle`); + const dockerFilesDirName = `kibana${scope.imageFlavor}-${scope.version}-docker-build-context`; const dockerFilesBuildDir = resolve(scope.dockerBuildDir, dockerFilesDirName); const dockerFilesOutputDir = config.resolveFromTarget(`${dockerFilesDirName}.tar.gz`); @@ -38,6 +38,17 @@ export async function bundleDockerFiles(config: Config, log: ToolingLog, scope: // dockerfiles folder await copyAll(resolve(scope.dockerBuildDir, 'bin'), resolve(dockerFilesBuildDir, 'bin')); await copyAll(resolve(scope.dockerBuildDir, 'config'), resolve(dockerFilesBuildDir, 'config')); + if (scope.ironbank) { + await copyAll(resolve(scope.dockerBuildDir), resolve(dockerFilesBuildDir), { + select: ['LICENSE'], + }); + const templates = ['hardening_manifest.yml', 'README.md']; + for (const template of templates) { + const file = readFileSync(resolve(__dirname, 'templates/ironbank', template)); + const output = Mustache.render(file.toString(), scope); + await write(resolve(dockerFilesBuildDir, template), output); + } + } // Compress dockerfiles dir created inside // docker build dir as output it as a target diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker similarity index 100% rename from src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker rename to src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/ironbank/LICENSE b/src/dev/build/tasks/os_packages/docker_generator/resources/ironbank/LICENSE new file mode 100644 index 00000000000000..632c3abe22e9be --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/ironbank/LICENSE @@ -0,0 +1,280 @@ +ELASTIC LICENSE AGREEMENT + +PLEASE READ CAREFULLY THIS ELASTIC LICENSE AGREEMENT (THIS "AGREEMENT"), WHICH +CONSTITUTES A LEGALLY BINDING AGREEMENT AND GOVERNS ALL OF YOUR USE OF ALL OF +THE ELASTIC SOFTWARE WITH WHICH THIS AGREEMENT IS INCLUDED ("ELASTIC SOFTWARE") +THAT IS PROVIDED IN OBJECT CODE FORMAT, AND, IN ACCORDANCE WITH SECTION 2 BELOW, +CERTAIN OF THE ELASTIC SOFTWARE THAT IS PROVIDED IN SOURCE CODE FORMAT. BY +INSTALLING OR USING ANY OF THE ELASTIC SOFTWARE GOVERNED BY THIS AGREEMENT, YOU +ARE ASSENTING TO THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE +WITH SUCH TERMS AND CONDITIONS, YOU MAY NOT INSTALL OR USE THE ELASTIC SOFTWARE +GOVERNED BY THIS AGREEMENT. IF YOU ARE INSTALLING OR USING THE SOFTWARE ON +BEHALF OF A LEGAL ENTITY, YOU REPRESENT AND WARRANT THAT YOU HAVE THE ACTUAL +AUTHORITY TO AGREE TO THE TERMS AND CONDITIONS OF THIS AGREEMENT ON BEHALF OF +SUCH ENTITY. + +Posted Date: April 20, 2018 + +This Agreement is entered into by and between Elasticsearch BV ("Elastic") and +You, or the legal entity on behalf of whom You are acting (as applicable, +"You"). + +1. OBJECT CODE END USER LICENSES, RESTRICTIONS AND THIRD PARTY OPEN SOURCE +SOFTWARE + + 1.1 Object Code End User License. Subject to the terms and conditions of + Section 1.2 of this Agreement, Elastic hereby grants to You, AT NO CHARGE and + for so long as you are not in breach of any provision of this Agreement, a + License to the Basic Features and Functions of the Elastic Software. + + 1.2 Reservation of Rights; Restrictions. As between Elastic and You, Elastic + and its licensors own all right, title and interest in and to the Elastic + Software, and except as expressly set forth in Sections 1.1, and 2.1 of this + Agreement, no other license to the Elastic Software is granted to You under + this Agreement, by implication, estoppel or otherwise. You agree not to: (i) + reverse engineer or decompile, decrypt, disassemble or otherwise reduce any + Elastic Software provided to You in Object Code, or any portion thereof, to + Source Code, except and only to the extent any such restriction is prohibited + by applicable law, (ii) except as expressly permitted in this Agreement, + prepare derivative works from, modify, copy or use the Elastic Software Object + Code or the Commercial Software Source Code in any manner; (iii) except as + expressly permitted in Section 1.1 above, transfer, sell, rent, lease, + distribute, sublicense, loan or otherwise transfer, Elastic Software Object + Code, in whole or in part, to any third party; (iv) use Elastic Software + Object Code for providing time-sharing services, any software-as-a-service, + service bureau services or as part of an application services provider or + other service offering (collectively, "SaaS Offering") where obtaining access + to the Elastic Software or the features and functions of the Elastic Software + is a primary reason or substantial motivation for users of the SaaS Offering + to access and/or use the SaaS Offering ("Prohibited SaaS Offering"); (v) + circumvent the limitations on use of Elastic Software provided to You in + Object Code format that are imposed or preserved by any License Key, or (vi) + alter or remove any Marks and Notices in the Elastic Software. If You have any + question as to whether a specific SaaS Offering constitutes a Prohibited SaaS + Offering, or are interested in obtaining Elastic's permission to engage in + commercial or non-commercial distribution of the Elastic Software, please + contact elastic_license@elastic.co. + + 1.3 Third Party Open Source Software. The Commercial Software may contain or + be provided with third party open source libraries, components, utilities and + other open source software (collectively, "Open Source Software"), which Open + Source Software may have applicable license terms as identified on a website + designated by Elastic. Notwithstanding anything to the contrary herein, use of + the Open Source Software shall be subject to the license terms and conditions + applicable to such Open Source Software, to the extent required by the + applicable licensor (which terms shall not restrict the license rights granted + to You hereunder, but may contain additional rights). To the extent any + condition of this Agreement conflicts with any license to the Open Source + Software, the Open Source Software license will govern with respect to such + Open Source Software only. Elastic may also separately provide you with + certain open source software that is licensed by Elastic. Your use of such + Elastic open source software will not be governed by this Agreement, but by + the applicable open source license terms. + +2. COMMERCIAL SOFTWARE SOURCE CODE + + 2.1 Limited License. Subject to the terms and conditions of Section 2.2 of + this Agreement, Elastic hereby grants to You, AT NO CHARGE and for so long as + you are not in breach of any provision of this Agreement, a limited, + non-exclusive, non-transferable, fully paid up royalty free right and license + to the Commercial Software in Source Code format, without the right to grant + or authorize sublicenses, to prepare Derivative Works of the Commercial + Software, provided You (i) do not hack the licensing mechanism, or otherwise + circumvent the intended limitations on the use of Elastic Software to enable + features other than Basic Features and Functions or those features You are + entitled to as part of a Subscription, and (ii) use the resulting object code + only for reasonable testing purposes. + + 2.2 Restrictions. Nothing in Section 2.1 grants You the right to (i) use the + Commercial Software Source Code other than in accordance with Section 2.1 + above, (ii) use a Derivative Work of the Commercial Software outside of a + Non-production Environment, in any production capacity, on a temporary or + permanent basis, or (iii) transfer, sell, rent, lease, distribute, sublicense, + loan or otherwise make available the Commercial Software Source Code, in whole + or in part, to any third party. Notwithstanding the foregoing, You may + maintain a copy of the repository in which the Source Code of the Commercial + Software resides and that copy may be publicly accessible, provided that you + include this Agreement with Your copy of the repository. + +3. TERMINATION + + 3.1 Termination. This Agreement will automatically terminate, whether or not + You receive notice of such Termination from Elastic, if You breach any of its + provisions. + + 3.2 Post Termination. Upon any termination of this Agreement, for any reason, + You shall promptly cease the use of the Elastic Software in Object Code format + and cease use of the Commercial Software in Source Code format. For the + avoidance of doubt, termination of this Agreement will not affect Your right + to use Elastic Software, in either Object Code or Source Code formats, made + available under the Apache License Version 2.0. + + 3.3 Survival. Sections 1.2, 2.2. 3.3, 4 and 5 shall survive any termination or + expiration of this Agreement. + +4. DISCLAIMER OF WARRANTIES AND LIMITATION OF LIABILITY + + 4.1 Disclaimer of Warranties. TO THE MAXIMUM EXTENT PERMITTED UNDER APPLICABLE + LAW, THE ELASTIC SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, + AND ELASTIC AND ITS LICENSORS MAKE NO WARRANTIES WHETHER EXPRESSED, IMPLIED OR + STATUTORY REGARDING OR RELATING TO THE ELASTIC SOFTWARE. TO THE MAXIMUM EXTENT + PERMITTED UNDER APPLICABLE LAW, ELASTIC AND ITS LICENSORS SPECIFICALLY + DISCLAIM ALL IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE AND NON-INFRINGEMENT WITH RESPECT TO THE ELASTIC SOFTWARE, AND WITH + RESPECT TO THE USE OF THE FOREGOING. FURTHER, ELASTIC DOES NOT WARRANT RESULTS + OF USE OR THAT THE ELASTIC SOFTWARE WILL BE ERROR FREE OR THAT THE USE OF THE + ELASTIC SOFTWARE WILL BE UNINTERRUPTED. + + 4.2 Limitation of Liability. IN NO EVENT SHALL ELASTIC OR ITS LICENSORS BE + LIABLE TO YOU OR ANY THIRD PARTY FOR ANY DIRECT OR INDIRECT DAMAGES, + INCLUDING, WITHOUT LIMITATION, FOR ANY LOSS OF PROFITS, LOSS OF USE, BUSINESS + INTERRUPTION, LOSS OF DATA, COST OF SUBSTITUTE GOODS OR SERVICES, OR FOR ANY + SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES OF ANY KIND, IN CONNECTION WITH + OR ARISING OUT OF THE USE OR INABILITY TO USE THE ELASTIC SOFTWARE, OR THE + PERFORMANCE OF OR FAILURE TO PERFORM THIS AGREEMENT, WHETHER ALLEGED AS A + BREACH OF CONTRACT OR TORTIOUS CONDUCT, INCLUDING NEGLIGENCE, EVEN IF ELASTIC + HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +5. MISCELLANEOUS + + This Agreement completely and exclusively states the entire agreement of the + parties regarding the subject matter herein, and it supersedes, and its terms + govern, all prior proposals, agreements, or other communications between the + parties, oral or written, regarding such subject matter. This Agreement may be + modified by Elastic from time to time, and any such modifications will be + effective upon the "Posted Date" set forth at the top of the modified + Agreement. If any provision hereof is held unenforceable, this Agreement will + continue without said provision and be interpreted to reflect the original + intent of the parties. This Agreement and any non-contractual obligation + arising out of or in connection with it, is governed exclusively by Dutch law. + This Agreement shall not be governed by the 1980 UN Convention on Contracts + for the International Sale of Goods. All disputes arising out of or in + connection with this Agreement, including its existence and validity, shall be + resolved by the courts with jurisdiction in Amsterdam, The Netherlands, except + where mandatory law provides for the courts at another location in The + Netherlands to have jurisdiction. The parties hereby irrevocably waive any and + all claims and defenses either might otherwise have in any such action or + proceeding in any of such courts based upon any alleged lack of personal + jurisdiction, improper venue, forum non conveniens or any similar claim or + defense. A breach or threatened breach, by You of Section 2 may cause + irreparable harm for which damages at law may not provide adequate relief, and + therefore Elastic shall be entitled to seek injunctive relief without being + required to post a bond. You may not assign this Agreement (including by + operation of law in connection with a merger or acquisition), in whole or in + part to any third party without the prior written consent of Elastic, which + may be withheld or granted by Elastic in its sole and absolute discretion. + Any assignment in violation of the preceding sentence is void. Notices to + Elastic may also be sent to legal@elastic.co. + +6. DEFINITIONS + + The following terms have the meanings ascribed: + + 6.1 "Affiliate" means, with respect to a party, any entity that controls, is + controlled by, or which is under common control with, such party, where + "control" means ownership of at least fifty percent (50%) of the outstanding + voting shares of the entity, or the contractual right to establish policy for, + and manage the operations of, the entity. + + 6.2 "Basic Features and Functions" means those features and functions of the + Elastic Software that are eligible for use under a Basic license, as set forth + at https://www.elastic.co/subscriptions, as may be modified by Elastic from + time to time. + + 6.3 "Commercial Software" means the Elastic Software Source Code in any file + containing a header stating the contents are subject to the Elastic License or + which is contained in the repository folder labeled "x-pack", unless a LICENSE + file present in the directory subtree declares a different license. + + 6.4 "Derivative Work of the Commercial Software" means, for purposes of this + Agreement, any modification(s) or enhancement(s) to the Commercial Software, + which represent, as a whole, an original work of authorship. + + 6.5 "License" means a limited, non-exclusive, non-transferable, fully paid up, + royalty free, right and license, without the right to grant or authorize + sublicenses, solely for Your internal business operations to (i) install and + use the applicable Features and Functions of the Elastic Software in Object + Code, and (ii) permit Contractors and Your Affiliates to use the Elastic + software as set forth in (i) above, provided that such use by Contractors must + be solely for Your benefit and/or the benefit of Your Affiliates, and You + shall be responsible for all acts and omissions of such Contractors and + Affiliates in connection with their use of the Elastic software that are + contrary to the terms and conditions of this Agreement. + + 6.6 "License Key" means a sequence of bytes, including but not limited to a + JSON blob, that is used to enable certain features and functions of the + Elastic Software. + + 6.7 "Marks and Notices" means all Elastic trademarks, trade names, logos and + notices present on the Documentation as originally provided by Elastic. + + 6.8 "Non-production Environment" means an environment for development, testing + or quality assurance, where software is not used for production purposes. + + 6.9 "Object Code" means any form resulting from mechanical transformation or + translation of Source Code form, including but not limited to compiled object + code, generated documentation, and conversions to other media types. + + 6.10 "Source Code" means the preferred form of computer software for making + modifications, including but not limited to software source code, + documentation source, and configuration files. + + 6.11 "Subscription" means the right to receive Support Services and a License + to the Commercial Software. + + +GOVERNMENT END USER ADDENDUM TO THE ELASTIC LICENSE AGREEMENT + + This ADDENDUM TO THE ELASTIC LICENSE AGREEMENT (this "Addendum") applies +only to U.S. Federal Government, State Government, and Local Government +entities ("Government End Users") of the Elastic Software. This Addendum is +subject to, and hereby incorporated into, the Elastic License Agreement, +which is being entered into as of even date herewith, by Elastic and You (the +"Agreement"). This Addendum sets forth additional terms and conditions +related to Your use of the Elastic Software. Capitalized terms not defined in +this Addendum have the meaning set forth in the Agreement. + + 1. LIMITED LICENSE TO DISTRIBUTE (DSOP ONLY). Subject to the terms and +conditions of the Agreement (including this Addendum), Elastic grants the +Department of Defense Enterprise DevSecOps Initiative (DSOP) a royalty-free, +non-exclusive, non-transferable, limited license to reproduce and distribute +the Elastic Software solely through a software distribution repository +controlled and managed by DSOP, provided that DSOP: (i) distributes the +Elastic Software complete and unmodified, inclusive of the Agreement +(including this Addendum) and (ii) does not remove or alter any proprietary +legends or notices contained in the Elastic Software. + + 2. CHOICE OF LAW. The choice of law and venue provisions set forth shall +prevail over those set forth in Section 5 of the Agreement. + + "For U.S. Federal Government Entity End Users. This Agreement and any + non-contractual obligation arising out of or in connection with it, is + governed exclusively by U.S. Federal law. To the extent permitted by + federal law, the laws of the State of Delaware (excluding Delaware choice + of law rules) will apply in the absence of applicable federal law. + + For State and Local Government Entity End Users. This Agreement and any + non-contractual obligation arising out of or in connection with it, is + governed exclusively by the laws of the state in which you are located + without reference to conflict of laws. Furthermore, the Parties agree that + the Uniform Computer Information Transactions Act or any version thereof, + adopted by any state in any form ('UCITA'), shall not apply to this + Agreement and, to the extent that UCITA is applicable, the Parties agree to + opt out of the applicability of UCITA pursuant to the opt-out provision(s) + contained therein." + + 3. ELASTIC LICENSE MODIFICATION. Section 5 of the Agreement is hereby +amended to replace + + "This Agreement may be modified by Elastic from time to time, and any + such modifications will be effective upon the "Posted Date" set forth at + the top of the modified Agreement." + + with: + + "This Agreement may be modified by Elastic from time to time; provided, + however, that any such modifications shall apply only to Elastic Software + that is installed after the "Posted Date" set forth at the top of the + modified Agreement." + +V100820.0 diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 18c04b0428afa6..21d2582f205f37 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -12,6 +12,7 @@ import { promisify } from 'util'; import { ToolingLog } from '@kbn/dev-utils'; +import { branch } from '../../../../../../package.json'; import { write, copyAll, mkdirp, exec, Config, Build } from '../../../lib'; import * as dockerTemplates from './templates'; import { TemplateContext } from './template_context'; @@ -30,21 +31,26 @@ export async function runDockerGenerator( architecture?: string; context: boolean; image: boolean; - ubi: boolean; + ubi?: boolean; + ironbank?: boolean; } ) { // UBI var config const baseOSImage = flags.ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; const ubiVersionTag = 'ubi8'; - const ubiImageFlavor = flags.ubi ? `-${ubiVersionTag}` : ''; + + let imageFlavor = ''; + if (flags.ubi) imageFlavor += `-${ubiVersionTag}`; + if (flags.ironbank) imageFlavor += '-ironbank'; + if (build.isOss()) imageFlavor += '-oss'; // General docker var config const license = build.isOss() ? 'ASL 2.0' : 'Elastic License'; - const imageFlavor = build.isOss() ? '-oss' : ''; const imageTag = 'docker.elastic.co/kibana/kibana'; const version = config.getBuildVersion(); const artifactArchitecture = flags.architecture === 'aarch64' ? 'aarch64' : 'x86_64'; - const artifactPrefix = `kibana${imageFlavor}-${version}-linux`; + const artifactFlavor = build.isOss() ? '-oss' : ''; + const artifactPrefix = `kibana${artifactFlavor}-${version}-linux`; const artifactTarball = `${artifactPrefix}-${artifactArchitecture}.tar.gz`; const artifactsDir = config.resolveFromTarget('.'); const dockerBuildDate = new Date().toISOString(); @@ -52,26 +58,27 @@ export async function runDockerGenerator( const dockerBuildDir = config.resolveFromRepo( 'build', 'kibana-docker', - build.isOss() ? `oss` : `default${ubiImageFlavor}` + build.isOss() ? `oss` : `default${imageFlavor}` ); const imageArchitecture = flags.architecture === 'aarch64' ? '-aarch64' : ''; const dockerTargetFilename = config.resolveFromTarget( - `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker-image${imageArchitecture}.tar.gz` + `kibana${imageFlavor}-${version}-docker-image${imageArchitecture}.tar.gz` ); const scope: TemplateContext = { artifactPrefix, artifactTarball, imageFlavor, version, + branch, license, artifactsDir, imageTag, dockerBuildDir, dockerTargetFilename, baseOSImage, - ubiImageFlavor, dockerBuildDate, ubi: flags.ubi, + ironbank: flags.ironbank, architecture: flags.architecture, revision: config.getBuildSha(), }; @@ -107,10 +114,17 @@ export async function runDockerGenerator( // in order to build the docker image accordingly the dockerfile defined // under templates/kibana_yml.template/js await copyAll( - config.resolveFromRepo('src/dev/build/tasks/os_packages/docker_generator/resources'), + config.resolveFromRepo('src/dev/build/tasks/os_packages/docker_generator/resources/base'), dockerBuildDir ); + if (flags.ironbank) { + await copyAll( + config.resolveFromRepo('src/dev/build/tasks/os_packages/docker_generator/resources/ironbank'), + dockerBuildDir + ); + } + // Build docker image into the target folder // In order to do this we just call the file we // created from the templates/build_docker_sh.template.js diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index 845d0449437ba6..9c9949c9f57eab 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -9,6 +9,7 @@ export interface TemplateContext { artifactPrefix: string; artifactTarball: string; + branch: string; imageFlavor: string; version: string; license: string; @@ -17,10 +18,10 @@ export interface TemplateContext { dockerBuildDir: string; dockerTargetFilename: string; baseOSImage: string; - ubiImageFlavor: string; dockerBuildDate: string; usePublicArtifact?: boolean; - ubi: boolean; + ubi?: boolean; + ironbank?: boolean; revision: string; architecture?: string; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile similarity index 100% rename from src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile rename to src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index 89e6cc1040a029..05b9b4d100c535 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -16,7 +16,6 @@ function generator({ version, dockerTargetFilename, baseOSImage, - ubiImageFlavor, architecture, }: TemplateContext) { return dedent(` @@ -54,10 +53,10 @@ function generator({ retry_docker_pull ${baseOSImage} - echo "Building: kibana${imageFlavor}${ubiImageFlavor}-docker"; \\ - docker build -t ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} -f Dockerfile . || exit 1; + echo "Building: kibana${imageFlavor}-docker"; \\ + docker build -t ${imageTag}${imageFlavor}:${version} -f Dockerfile . || exit 1; - docker save ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} | gzip -c > ${dockerTargetFilename} + docker save ${imageTag}${imageFlavor}:${version} | gzip -c > ${dockerTargetFilename} exit 0 `); diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts index 01a45a48094312..e668299a3acc38 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts @@ -13,10 +13,10 @@ import Mustache from 'mustache'; import { TemplateContext } from '../template_context'; function generator(options: TemplateContext) { - const template = readFileSync(resolve(__dirname, './Dockerfile')); + const dir = options.ironbank ? 'ironbank' : 'base'; + const template = readFileSync(resolve(__dirname, dir, './Dockerfile')); return Mustache.render(template.toString(), { - packageManager: options.ubiImageFlavor ? 'microdnf' : 'yum', - tiniBin: options.architecture === 'aarch64' ? 'tini-arm64' : 'tini-amd64', + packageManager: options.ubi ? 'microdnf' : 'yum', ...options, }); } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile new file mode 100644 index 00000000000000..6893883bf16a47 --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/Dockerfile @@ -0,0 +1,77 @@ +################################################################################ +# Build stage 0 +# Extract Kibana and make various file manipulations. +################################################################################ +ARG BASE_REGISTRY=registry1.dsop.io +ARG BASE_IMAGE=redhat/ubi/ubi8 +ARG BASE_TAG=8.3 + +FROM ${BASE_REGISTRY}/${BASE_IMAGE}:${BASE_TAG} as prep_files + +RUN yum update --setopt=tsflags=nodocs -y && \ + yum install -y tar gzip && \ + yum clean all + +RUN mkdir /usr/share/kibana +WORKDIR /usr/share/kibana +COPY --chown=1000:0 {{artifactTarball}} . +RUN tar --strip-components=1 -zxf {{artifactTarball}} + +# Ensure that group permissions are the same as user permissions. +# This will help when relying on GID-0 to run Kibana, rather than UID-1000. +# OpenShift does this, for example. +# REF: https://docs.openshift.org/latest/creating_images/guidelines.html +RUN chmod -R g=u /usr/share/kibana + + +################################################################################ +# Build stage 1 +# Copy prepared files from the previous stage and complete the image. +################################################################################ +FROM ${BASE_REGISTRY}/${BASE_IMAGE}:${BASE_TAG} +EXPOSE 5601 + +RUN yum update --setopt=tsflags=nodocs -y && \ + yum install -y fontconfig freetype shadow-utils nss && \ + yum clean all + +COPY LICENSE /licenses/elastic-kibana + +# Add a dumb init process +COPY tini /bin/tini +RUN chmod +x /bin/tini + +# Noto Fonts +RUN mkdir /usr/share/fonts/local +COPY NotoSansCJK-Regular.ttc /usr/share/fonts/local/NotoSansCJK-Regular.ttc +RUN fc-cache -v + +# Bring in Kibana from the initial stage. +COPY --from=prep_files --chown=1000:0 /usr/share/kibana /usr/share/kibana +WORKDIR /usr/share/kibana +RUN ln -s /usr/share/kibana /opt/kibana + +ENV ELASTIC_CONTAINER true +ENV PATH=/usr/share/kibana/bin:$PATH + +# Set some Kibana configuration defaults. +COPY --chown=1000:0 config/kibana.yml /usr/share/kibana/config/kibana.yml + +# Add the launcher/wrapper script. It knows how to interpret environment +# variables and translate them to Kibana CLI options. +COPY --chown=1000:0 scripts/kibana-docker /usr/local/bin/ + +# Remove the suid bit everywhere to mitigate "Stack Clash" +RUN find / -xdev -perm -4000 -exec chmod u-s {} + + +# Provide a non-root user to run the process. +RUN groupadd --gid 1000 kibana && \ + useradd --uid 1000 --gid 1000 -G 0 \ + --home-dir /usr/share/kibana --no-create-home \ + kibana + +ENTRYPOINT ["/bin/tini", "--"] + +CMD ["/usr/local/bin/kibana-docker"] + +HEALTHCHECK --interval=10s --timeout=5s --start-period=1m --retries=5 CMD curl -I -f --max-time 5 http://localhost:5601 || exit 1 diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/README.md b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/README.md new file mode 100644 index 00000000000000..d297d135149f47 --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/README.md @@ -0,0 +1,39 @@ +# Kibana + +**Kibana** lets you visualize your Elasticsearch data and navigate the Elastic Stack, +so you can do anything from learning why you're getting paged at 2:00 a.m. to +understanding the impact rain might have on your quarterly numbers. + +For more information about Kibana, please visit +https://www.elastic.co/products/kibana. + +### Installation instructions + +Please follow the documentation on [running Kibana on Docker](https://www.elastic.co/guide/en/kibana/{{branch}}/docker.html). + +### Where to file issues and PRs + +- [Issues](https://github.com/elastic/kibana/issues) +- [PRs](https://github.com/elastic/kibana/pulls) + +### DoD Restrictions + +Due to the [NODE-SECURITY-1184](https://www.npmjs.com/advisories/1184) issue, Kibana users should not use the `ALL_PROXY` environment variable to specify a proxy when installing Kibana plugins with the kibana-plugin command line application. + +### Where to get help + +- [Kibana Discuss Forums](https://discuss.elastic.co/c/kibana) +- [Kibana Documentation](https://www.elastic.co/guide/en/kibana/current/index.html) + +### Still need help? + +You can learn more about the Elastic Community and also understand how to get more help +visiting [Elastic Community](https://www.elastic.co/community). + +This software is governed by the [Elastic +License](https://github.com/elastic/elasticsearch/blob/{{branch}}/licenses/ELASTIC-LICENSE.txt), +and includes the full set of [free +features](https://www.elastic.co/subscriptions). + +View the detailed release notes +[here](https://www.elastic.co/guide/en/elasticsearch/reference/{{branch}}/es-release-notes.html). diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yml b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yml new file mode 100644 index 00000000000000..8de5ac29733588 --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/ironbank/hardening_manifest.yml @@ -0,0 +1,58 @@ +--- +apiVersion: v1 + +# The repository name in registry1, excluding /ironbank/ +name: 'elastic/kibana/kibana' + +# List of tags to push for the repository in registry1 +# The most specific version should be the first tag and will be shown +# on ironbank.dsop.io +tags: + - '{{version}}' + - 'latest' + +# Build args passed to Dockerfile ARGs +args: + BASE_IMAGE: 'redhat/ubi/ubi8' + BASE_TAG: '8.3' + +# Docker image labels +labels: + org.opencontainers.image.title: 'kibana' + org.opencontainers.image.description: 'Your window into the Elastic Stack.' + org.opencontainers.image.licenses: 'Elastic License' + org.opencontainers.image.url: 'https://www.elastic.co/products/kibana' + org.opencontainers.image.vendor: 'Elastic' + org.opencontainers.image.version: '{{version}}' + # mil.dso.ironbank.image.keywords: "" + # mil.dso.ironbank.image.type: "commercial" + mil.dso.ironbank.product.name: 'Kibana' + +# List of resources to make available to the offline build context +resources: + - filename: kibana-{{version}}-linux-x86_64.tar.gz + url: /kibana-{{version}}-linux-x86_64.tar.gz + validation: + type: sha512 + value: aa68f850cc09cf5dcb7c0b48bb8df788ca58eaad38d96141b8e59917fd38b42c728c0968f7cb2c8132c5aaeb595525cdde0859554346c496f53c569e03abe412 + - filename: tini + url: https://github.com/krallin/tini/releases/download/v0.19.0/tini-amd64 + validation: + type: sha512 + value: 8053cc21a3a9bdd6042a495349d1856ae8d3b3e7664c9654198de0087af031f5d41139ec85a2f5d7d2febd22ec3f280767ff23b9d5f63d490584e2b7ad3c218c + - filename: NotoSansCJK-Regular.ttc + url: https://github.com/googlefonts/noto-cjk/raw/NotoSansV2.001/NotoSansCJK-Regular.ttc + validation: + type: sha512 + value: 0ce56bde1853fed3e53282505bac65707385275a27816c29712ab04c187aa249797c82c58759b2b36c210d4e2683eda92359d739a8045cb8385c2c34d37cc9e1 + +# List of project maintainers +maintainers: + - email: 'tyler.smalley@elastic.co' + name: 'Tyler Smalley' + username: 'tylersmalley' + cht_member: false + - email: 'klepal_alexander@bah.com' + name: 'Alexander Klepal' + username: 'alexander.klepal' + cht_member: true diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index db7110d2d0875d..c4559029e5607a 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -25,7 +25,16 @@ echo "build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY" ### install dependencies ### echo " -- installing node.js dependencies" -yarn kbn bootstrap +yarn kbn bootstrap --verbose + +### +### upload ts-refs-cache artifacts as quickly as possible so they are available for download +### +if [[ "$BUILD_TS_REFS_CACHE_CAPTURE" == "true" ]]; then + cd "$KIBANA_DIR/target/ts_refs_cache" + gsutil cp "*.zip" 'gs://kibana-ci-ts-refs-cache/' + cd "$KIBANA_DIR" +fi ### ### Download es snapshots diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 14605208334606..2c9dfbe6fcc10f 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -124,7 +124,6 @@ export const REMOVE_EXTENSION = ['packages/kbn-plugin-generator/template/**/*.ej * @type {Array} */ export const TEMPORARILY_IGNORED_PATHS = [ - 'src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json', 'src/core/server/core_app/assets/favicons/android-chrome-192x192.png', 'src/core/server/core_app/assets/favicons/android-chrome-256x256.png', 'src/core/server/core_app/assets/favicons/android-chrome-512x512.png', diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts index 1f7bf18b5012d9..fc8911a2517733 100644 --- a/src/dev/typescript/build_ts_refs_cli.ts +++ b/src/dev/typescript/build_ts_refs_cli.ts @@ -6,28 +6,62 @@ * Side Public License, v 1. */ -import { run } from '@kbn/dev-utils'; +import Path from 'path'; + +import { run, REPO_ROOT } from '@kbn/dev-utils'; import del from 'del'; +import { RefOutputCache } from './ref_output_cache'; import { buildAllTsRefs, REF_CONFIG_PATHS } from './build_ts_refs'; import { getOutputsDeep } from './ts_configfile'; import { concurrentMap } from './concurrent_map'; +const CACHE_WORKING_DIR = Path.resolve(REPO_ROOT, 'data/ts_refs_output_cache'); + export async function runBuildRefsCli() { run( async ({ log, flags }) => { - if (flags.clean) { - const outDirs = getOutputsDeep(REF_CONFIG_PATHS); + const outDirs = getOutputsDeep(REF_CONFIG_PATHS); + + const cacheEnabled = process.env.BUILD_TS_REFS_CACHE_ENABLE === 'true' || !!flags.cache; + const doCapture = process.env.BUILD_TS_REFS_CACHE_CAPTURE === 'true'; + const doClean = !!flags.clean || doCapture; + const doInitCache = cacheEnabled && !doClean; + + if (doClean) { log.info('deleting', outDirs.length, 'ts output directories'); await concurrentMap(100, outDirs, (outDir) => del(outDir)); } + let outputCache; + if (cacheEnabled) { + outputCache = await RefOutputCache.create({ + log, + outDirs, + repoRoot: REPO_ROOT, + workingDir: CACHE_WORKING_DIR, + upstreamUrl: 'https://github.com/elastic/kibana.git', + }); + } + + if (outputCache && doInitCache) { + await outputCache.initCaches(); + } + await buildAllTsRefs(log); + + if (outputCache && doCapture) { + await outputCache.captureCache(Path.resolve(REPO_ROOT, 'target/ts_refs_cache')); + } + + if (outputCache) { + await outputCache.cleanup(); + } }, { description: 'Build TypeScript projects', flags: { - boolean: ['clean'], + boolean: ['clean', 'cache'], }, log: { defaultLevel: 'debug', diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index a5e6c4bef832c3..29379bbb31ee12 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -28,10 +28,6 @@ export const PROJECTS = [ name: 'apm/ftr_e2e', disableTypeCheck: true, }), - new Project(resolve(REPO_ROOT, 'x-pack/plugins/apm/scripts/tsconfig.json'), { - name: 'apm/scripts', - disableTypeCheck: true, - }), // NOTE: using glob.sync rather than glob-all or globby // because it takes less than 10 ms, while the other modules diff --git a/src/dev/typescript/ref_output_cache/README.md b/src/dev/typescript/ref_output_cache/README.md new file mode 100644 index 00000000000000..41506a118dcb9b --- /dev/null +++ b/src/dev/typescript/ref_output_cache/README.md @@ -0,0 +1,17 @@ +# `node scripts/build_ts_refs` output cache + +This module implements the logic for caching the output of building the ts refs and extracting those caches into the source repo to speed up the execution of this script. We've implemented this as a stop-gap solution while we migrate to Bazel which will handle caching the types produced by the +scripts independently and speed things up incredibly, but in the meantime we need something to fix the 10 minute bootstrap times we're seeing. + +How it works: + + 1. traverse the TS projects referenced from `tsconfig.refs.json` and collect their `compilerOptions.outDir` setting. + 2. determine the `upstreamBranch` by reading the `branch` property out of `package.json` + 3. fetch the latest changes from `https://github.com/elastic/kibana.git` for that branch + 4. determine the merge base between `HEAD` and the latest ref from the `upstreamBranch` + 5. check in the `data/ts_refs_output_cache/archives` dir (where we keep the 10 most recent downloads) and at `https://ts-refs-cache.kibana.dev/{sha}.zip` for the cache of the merge base commit, and up to 5 commits before that in the log, stopping once we find one that is available locally or was downloaded. + 6. check for the `.ts-ref-cache-merge-base` file in each `outDir`, which records the `mergeBase` that was used to initialize that `outDir`, if the file exists and matches the `sha` that we plan to use for our cache then exclude that `outDir` from getting initialized with the cache data + 7. for each `outDir` that either hasn't been initialized with cache data or was initialized with cache data from another merge base, delete the `outDir` and replace it with the copy stored in the downloaded cache + 1. if there isn't a cached version of that `outDir` replace it with an empty directory + 8. write the current `mergeBase` to the `.ts-ref-cache-merge-base` file in each `outDir` + 9. run `tsc`, which will only build things which have changed since the cache was created \ No newline at end of file diff --git a/src/dev/typescript/ref_output_cache/archives.ts b/src/dev/typescript/ref_output_cache/archives.ts new file mode 100644 index 00000000000000..4db40221809975 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/archives.ts @@ -0,0 +1,186 @@ +/* + * 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 Fs from 'fs/promises'; +import { createWriteStream } from 'fs'; +import Path from 'path'; +import { promisify } from 'util'; +import { pipeline } from 'stream'; + +import { ToolingLog } from '@kbn/dev-utils'; +import Axios from 'axios'; +import del from 'del'; + +// https://github.com/axios/axios/tree/ffea03453f77a8176c51554d5f6c3c6829294649/lib/adapters +// @ts-expect-error untyped internal module used to prevent axios from using xhr adapter in tests +import AxiosHttpAdapter from 'axios/lib/adapters/http'; + +interface Archive { + sha: string; + path: string; + time: number; +} + +const asyncPipeline = promisify(pipeline); + +async function getCacheNames(cacheDir: string) { + try { + return await Fs.readdir(cacheDir); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } + + throw error; + } +} + +export class Archives { + static async create(log: ToolingLog, workingDir: string) { + const dir = Path.resolve(workingDir, 'archives'); + const bySha = new Map(); + + for (const name of await getCacheNames(dir)) { + const path = Path.resolve(dir, name); + + if (!name.endsWith('.zip')) { + log.debug('deleting unexpected file in archives dir', path); + await Fs.unlink(path); + continue; + } + + const sha = name.replace('.zip', ''); + log.verbose('identified archive for', sha); + const s = await Fs.stat(path); + const time = Math.max(s.atimeMs, s.mtimeMs); + bySha.set(sha, { + path, + time, + sha, + }); + } + + return new Archives(log, workingDir, bySha); + } + + protected constructor( + private readonly log: ToolingLog, + private readonly workDir: string, + private readonly bySha: Map + ) {} + + size() { + return this.bySha.size; + } + + get(sha: string) { + return this.bySha.get(sha); + } + + async delete(sha: string) { + const archive = this.get(sha); + if (archive) { + await Fs.unlink(archive.path); + this.bySha.delete(sha); + } + } + + *[Symbol.iterator]() { + yield* this.bySha.values(); + } + + /** + * Attempt to download the cache for a given sha, adding it to this.bySha + * and returning true if successful, logging and returning false otherwise. + * + * @param sha the commit sha we should try to download the cache for + */ + async attemptToDownload(sha: string) { + if (this.bySha.has(sha)) { + return true; + } + + const url = `https://ts-refs-cache.kibana.dev/${sha}.zip`; + this.log.debug('attempting to download cache for', sha, 'from', url); + + const filename = `${sha}.zip`; + const target = Path.resolve(this.workDir, 'archives', `${filename}`); + const tmpTarget = `${target}.tmp`; + + try { + const resp = await Axios.request({ + url, + responseType: 'stream', + adapter: AxiosHttpAdapter, + }); + + await Fs.mkdir(Path.dirname(target), { recursive: true }); + await asyncPipeline(resp.data, createWriteStream(tmpTarget)); + this.log.debug('download complete, renaming tmp'); + + await Fs.rename(tmpTarget, target); + this.bySha.set(sha, { + sha, + path: target, + time: Date.now(), + }); + + this.log.debug('download of cache for', sha, 'complete'); + return true; + } catch (error) { + await del(tmpTarget, { force: true }); + + if (!error.response) { + this.log.debug(`failed to download cache, ignoring error:`, error.message); + return false; + } + + if (error.response.status === 404) { + return false; + } + + this.log.debug(`failed to download cache,`, error.response.status, 'response'); + } + } + + /** + * Iterate through a list of shas, which represent commits + * on our upstreamBranch, and look for caches which are + * already downloaded, or try to download them. If the cache + * for that commit is not available for any reason the next + * sha will be tried. + * + * If we reach the end of the list without any caches being + * available undefined is returned. + * + * @param shas shas for commits to try and find caches for + */ + async getFirstAvailable(shas: string[]): Promise { + if (!shas.length) { + throw new Error('no possible shas to pick archive from'); + } + + for (const sha of shas) { + let archive = this.bySha.get(sha); + + // if we don't have one locally try to download one + if (!archive && (await this.attemptToDownload(sha))) { + archive = this.bySha.get(sha); + } + + // if we found the archive return it + if (archive) { + return archive; + } + + this.log.debug('no archive available for', sha); + } + + return undefined; + } +} diff --git a/src/type_definitions/react_virtualized.d.ts b/src/dev/typescript/ref_output_cache/index.ts similarity index 83% rename from src/type_definitions/react_virtualized.d.ts rename to src/dev/typescript/ref_output_cache/index.ts index d78a159b715609..8d55a31a1771c5 100644 --- a/src/type_definitions/react_virtualized.d.ts +++ b/src/dev/typescript/ref_output_cache/index.ts @@ -6,6 +6,4 @@ * Side Public License, v 1. */ -declare module 'react-virtualized' { - export type ListProps = any; -} +export * from './ref_output_cache'; diff --git a/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip new file mode 100644 index 00000000000000..07c14c13488b5f Binary files /dev/null and b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip differ diff --git a/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/5678.zip b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/5678.zip new file mode 100644 index 00000000000000..9a30ffff55e0dd Binary files /dev/null and b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/5678.zip differ diff --git a/src/dev/typescript/ref_output_cache/integration_tests/archives.test.ts b/src/dev/typescript/ref_output_cache/integration_tests/archives.test.ts new file mode 100644 index 00000000000000..60ba3a4f659b3c --- /dev/null +++ b/src/dev/typescript/ref_output_cache/integration_tests/archives.test.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import { Readable } from 'stream'; + +import del from 'del'; +import cpy from 'cpy'; +import { + ToolingLog, + createAbsolutePathSerializer, + createRecursiveSerializer, + ToolingLogCollectingWriter, + createStripAnsiSerializer, +} from '@kbn/dev-utils'; + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); +expect.addSnapshotSerializer(createStripAnsiSerializer()); +expect.addSnapshotSerializer( + createRecursiveSerializer( + (v) => typeof v === 'object' && v && typeof v.time === 'number', + (v) => ({ ...v, time: '' }) + ) +); + +jest.mock('axios', () => { + return { + request: jest.fn(), + }; +}); +const mockRequest: jest.Mock = jest.requireMock('axios').request; + +import { Archives } from '../archives'; + +const FIXTURE = Path.resolve(__dirname, '__fixtures__'); +const TMP = Path.resolve(__dirname, '__tmp__'); + +beforeAll(() => del(TMP, { force: true })); +beforeEach(() => cpy('.', TMP, { cwd: FIXTURE, parents: true })); +afterEach(async () => { + await del(TMP, { force: true }); + jest.resetAllMocks(); +}); + +const readArchiveDir = () => + Fs.readdirSync(Path.resolve(TMP, 'archives')).sort((a, b) => a.localeCompare(b)); + +const log = new ToolingLog(); +const logWriter = new ToolingLogCollectingWriter(); +log.setWriters([logWriter]); +afterEach(() => (logWriter.messages.length = 0)); + +it('deletes invalid files', async () => { + const path = Path.resolve(TMP, 'archives/foo.txt'); + Fs.writeFileSync(path, 'hello'); + const archives = await Archives.create(log, TMP); + + expect(archives.size()).toBe(2); + expect(Fs.existsSync(path)).toBe(false); +}); + +it('exposes archives by sha', async () => { + const archives = await Archives.create(log, TMP); + expect(archives.get('1234')).toMatchInlineSnapshot(` + Object { + "path": /src/dev/typescript/ref_output_cache/integration_tests/__tmp__/archives/1234.zip, + "sha": "1234", + "time": "", + } + `); + expect(archives.get('5678')).toMatchInlineSnapshot(` + Object { + "path": /src/dev/typescript/ref_output_cache/integration_tests/__tmp__/archives/5678.zip, + "sha": "5678", + "time": "", + } + `); + expect(archives.get('foo')).toMatchInlineSnapshot(`undefined`); +}); + +it('deletes archives', async () => { + const archives = await Archives.create(log, TMP); + expect(archives.size()).toBe(2); + await archives.delete('1234'); + expect(archives.size()).toBe(1); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "5678.zip", + ] + `); +}); + +it('returns false when attempting to download for sha without cache', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error('404!'); + }); + + await expect(archives.attemptToDownload('foobar')).resolves.toBe(false); +}); + +it('returns true when able to download an archive for a sha', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + return { + data: Readable.from('foobar zip contents'), + }; + }); + + expect(archives.size()).toBe(2); + await expect(archives.attemptToDownload('foobar')).resolves.toBe(true); + expect(archives.size()).toBe(3); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + "foobar.zip", + ] + `); + expect(Fs.readFileSync(Path.resolve(TMP, 'archives/foobar.zip'), 'utf-8')).toBe( + 'foobar zip contents' + ); +}); + +it('returns true if attempting to download a cache which is already downloaded', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error(`it shouldn't try to download anything`); + }); + + expect(archives.size()).toBe(2); + await expect(archives.attemptToDownload('1234')).resolves.toBe(true); + expect(archives.size()).toBe(2); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + ] + `); +}); + +it('returns false and deletes the zip if the download fails part way', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + let readCounter = 0; + return { + data: new Readable({ + read() { + readCounter++; + if (readCounter === 1) { + this.push('foo'); + } else { + this.emit('error', new Error('something went wrong')); + } + }, + }), + }; + }); + + await expect(archives.attemptToDownload('foo')).resolves.toBe(false); + expect(archives.size()).toBe(2); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + ] + `); +}); + +it('resolves to first sha if it is available locally', async () => { + const archives = await Archives.create(log, TMP); + + expect(await archives.getFirstAvailable(['1234', '5678'])).toHaveProperty('sha', '1234'); + expect(await archives.getFirstAvailable(['5678', '1234'])).toHaveProperty('sha', '5678'); +}); + +it('resolves to first local sha when it tried to reach network and gets errors', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error('no network available'); + }); + + expect(await archives.getFirstAvailable(['foo', 'bar', '1234'])).toHaveProperty('sha', '1234'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg attempting to download cache for foo from https://ts-refs-cache.kibana.dev/foo.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for foo", + " debg attempting to download cache for bar from https://ts-refs-cache.kibana.dev/bar.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for bar", + ] + `); +}); + +it('resolves to first remote that downloads successfully', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation((params) => { + if (params.url === `https://ts-refs-cache.kibana.dev/bar.zip`) { + return { + data: Readable.from('bar cache data'), + }; + } + + throw new Error('no network available'); + }); + + const archive = await archives.getFirstAvailable(['foo', 'bar', '1234']); + expect(archive).toHaveProperty('sha', 'bar'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg attempting to download cache for foo from https://ts-refs-cache.kibana.dev/foo.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for foo", + " debg attempting to download cache for bar from https://ts-refs-cache.kibana.dev/bar.zip", + " debg download complete, renaming tmp", + " debg download of cache for bar complete", + ] + `); + + expect(Fs.readFileSync(archive!.path, 'utf-8')).toBe('bar cache data'); +}); diff --git a/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts b/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts new file mode 100644 index 00000000000000..2bc75785ee6a73 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; + +import del from 'del'; +import cpy from 'cpy'; +import globby from 'globby'; +import { + ToolingLog, + createAbsolutePathSerializer, + createStripAnsiSerializer, + ToolingLogCollectingWriter, +} from '@kbn/dev-utils'; + +import { RefOutputCache, OUTDIR_MERGE_BASE_FILENAME } from '../ref_output_cache'; +import { Archives } from '../archives'; +import type { RepoInfo } from '../repo_info'; + +jest.mock('../repo_info'); +const { RepoInfo: MockRepoInfo } = jest.requireMock('../repo_info'); + +jest.mock('axios'); +const { request: mockRequest } = jest.requireMock('axios'); + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); +expect.addSnapshotSerializer(createStripAnsiSerializer()); + +const FIXTURE = Path.resolve(__dirname, '__fixtures__'); +const TMP = Path.resolve(__dirname, '__tmp__'); +const repo: jest.Mocked = new MockRepoInfo(); +const log = new ToolingLog(); +const logWriter = new ToolingLogCollectingWriter(); +log.setWriters([logWriter]); + +beforeAll(() => del(TMP, { force: true })); +beforeEach(() => cpy('.', TMP, { cwd: FIXTURE, parents: true })); +afterEach(async () => { + await del(TMP, { force: true }); + jest.resetAllMocks(); + logWriter.messages.length = 0; +}); + +it('creates and extracts caches, ingoring dirs with matching merge-base file and placing merge-base files', async () => { + // setup repo mock + const HEAD = 'abcdefg'; + repo.getHeadSha.mockResolvedValue(HEAD); + repo.getRelative.mockImplementation((path) => Path.relative(TMP, path)); + repo.getRecentShasFrom.mockResolvedValue(['5678', '1234']); + + // create two fake outDirs + const outDirs = [Path.resolve(TMP, 'out/foo'), Path.resolve(TMP, 'out/bar')]; + for (const dir of outDirs) { + Fs.mkdirSync(dir, { recursive: true }); + Fs.writeFileSync(Path.resolve(dir, 'test'), 'hello world'); + } + + // init an archives instance using tmp + const archives = await Archives.create(log, TMP); + + // init the RefOutputCache with our mock data + const refOutputCache = new RefOutputCache(log, repo, archives, outDirs, HEAD); + + // create the new cache right in the archives dir + await refOutputCache.captureCache(Path.resolve(TMP)); + const cachePath = Path.resolve(TMP, `${HEAD}.zip`); + + // check that the cache was created and stored in the archives + if (!Fs.existsSync(cachePath)) { + throw new Error('zip was not created as expected'); + } + + mockRequest.mockImplementation((params: any) => { + if (params.url.endsWith(`${HEAD}.zip`)) { + return { + data: Fs.createReadStream(cachePath), + }; + } + + throw new Error(`unexpected url: ${params.url}`); + }); + + // modify the files in the outDirs so we can see which ones are restored from the cache + for (const dir of outDirs) { + Fs.writeFileSync(Path.resolve(dir, 'test'), 'not cleared by cache init'); + } + // add the mergeBase to the first outDir so that it is ignored + Fs.writeFileSync(Path.resolve(outDirs[0], OUTDIR_MERGE_BASE_FILENAME), HEAD); + + // rebuild the outDir from the refOutputCache + await refOutputCache.initCaches(); + + const files = Object.fromEntries( + globby + .sync(outDirs, { dot: true }) + .map((path) => [Path.relative(TMP, path), Fs.readFileSync(path, 'utf-8')]) + ); + + expect(files).toMatchInlineSnapshot(` + Object { + "out/bar/.ts-ref-cache-merge-base": "abcdefg", + "out/bar/test": "hello world", + "out/foo/.ts-ref-cache-merge-base": "abcdefg", + "out/foo/test": "not cleared by cache init", + } + `); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg writing ts-ref cache to abcdefg.zip", + " succ wrote archive to abcdefg.zip", + " debg attempting to download cache for abcdefg from https://ts-refs-cache.kibana.dev/abcdefg.zip", + " debg download complete, renaming tmp", + " debg download of cache for abcdefg complete", + " debg extracting archives/abcdefg.zip to rebuild caches in 1 outDirs", + " debg [out/bar] clearing outDir and replacing with cache", + ] + `); +}); + +it('cleans up oldest archives when there are more than 10', async () => { + for (let i = 0; i < 100; i++) { + const time = i * 10_000; + const path = Path.resolve(TMP, `archives/${time}.zip`); + Fs.writeFileSync(path, ''); + Fs.utimesSync(path, time, time); + } + + const archives = await Archives.create(log, TMP); + const cache = new RefOutputCache(log, repo, archives, [], '1234'); + expect(cache.archives.size()).toBe(102); + await cache.cleanup(); + expect(cache.archives.size()).toBe(10); + expect(Fs.readdirSync(Path.resolve(TMP, 'archives')).sort((a, b) => a.localeCompare(b))) + .toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + "920000.zip", + "930000.zip", + "940000.zip", + "950000.zip", + "960000.zip", + "970000.zip", + "980000.zip", + "990000.zip", + ] + `); +}); diff --git a/src/dev/typescript/ref_output_cache/ref_output_cache.ts b/src/dev/typescript/ref_output_cache/ref_output_cache.ts new file mode 100644 index 00000000000000..342470ce0c6e36 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/ref_output_cache.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs/promises'; + +import { ToolingLog, kibanaPackageJson } from '@kbn/dev-utils'; +import del from 'del'; +import tempy from 'tempy'; + +import { Archives } from './archives'; +import { unzip, zip } from './zip'; +import { concurrentMap } from '../concurrent_map'; +import { RepoInfo } from './repo_info'; + +export const OUTDIR_MERGE_BASE_FILENAME = '.ts-ref-cache-merge-base'; + +export async function matchMergeBase(outDir: string, sha: string) { + try { + const existing = await Fs.readFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), 'utf8'); + return existing === sha; + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +export async function isDir(path: string) { + try { + return (await Fs.stat(path)).isDirectory(); + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +export class RefOutputCache { + static async create(options: { + log: ToolingLog; + workingDir: string; + outDirs: string[]; + repoRoot: string; + upstreamUrl: string; + }) { + const repoInfo = new RepoInfo(options.log, options.repoRoot, options.upstreamUrl); + const archives = await Archives.create(options.log, options.workingDir); + + const upstreamBranch: string = kibanaPackageJson.branch; + const mergeBase = await repoInfo.getMergeBase('HEAD', upstreamBranch); + + return new RefOutputCache(options.log, repoInfo, archives, options.outDirs, mergeBase); + } + + constructor( + private readonly log: ToolingLog, + private readonly repo: RepoInfo, + public readonly archives: Archives, + private readonly outDirs: string[], + private readonly mergeBase: string + ) {} + + /** + * Find the most recent cache/archive of the outDirs and replace the outDirs + * on disk with the files in the cache if the outDir has an outdated merge-base + * written to the directory. + */ + async initCaches() { + const archive = + this.archives.get(this.mergeBase) ?? + (await this.archives.getFirstAvailable([ + this.mergeBase, + ...(await this.repo.getRecentShasFrom(this.mergeBase, 5)), + ])); + + if (!archive) { + return; + } + + const outdatedOutDirs = ( + await concurrentMap(100, this.outDirs, async (outDir) => ({ + path: outDir, + outdated: !(await matchMergeBase(outDir, archive.sha)), + })) + ) + .filter((o) => o.outdated) + .map((o) => o.path); + + if (!outdatedOutDirs.length) { + this.log.debug('all outDirs have the most recent cache'); + return; + } + + const tmpDir = tempy.directory(); + this.log.debug( + 'extracting', + this.repo.getRelative(archive.path), + 'to rebuild caches in', + outdatedOutDirs.length, + 'outDirs' + ); + await unzip(archive.path, tmpDir); + + const cacheNames = await Fs.readdir(tmpDir); + + await concurrentMap(50, outdatedOutDirs, async (outDir) => { + const relative = this.repo.getRelative(outDir); + const cacheName = `${relative.split(Path.sep).join('__')}.zip`; + + if (!cacheNames.includes(cacheName)) { + this.log.debug(`[${relative}] not in cache`); + await Fs.mkdir(outDir, { recursive: true }); + await Fs.writeFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), archive.sha); + return; + } + + if (await matchMergeBase(outDir, archive.sha)) { + this.log.debug(`[${relative}] keeping outdir, created from selected sha`); + return; + } + + this.log.debug(`[${relative}] clearing outDir and replacing with cache`); + await del(outDir); + await unzip(Path.resolve(tmpDir, cacheName), outDir); + await Fs.writeFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), archive.sha); + }); + } + + /** + * Iterate through the outDirs, zip them up, and then zip up those zips + * into a single file which we can upload/download/extract just a portion + * of the archive. + * + * @param outputDir directory that the {HEAD}.zip file should be written to + */ + async captureCache(outputDir: string) { + const tmpDir = tempy.directory(); + const currentSha = await this.repo.getHeadSha(); + const outputPath = Path.resolve(outputDir, `${currentSha}.zip`); + const relativeOutputPath = this.repo.getRelative(outputPath); + + this.log.debug('writing ts-ref cache to', relativeOutputPath); + + const subZips: Array<[string, string]> = []; + + await Promise.all( + this.outDirs.map(async (absolute) => { + const relative = this.repo.getRelative(absolute); + const subZipName = `${relative.split(Path.sep).join('__')}.zip`; + const subZipPath = Path.resolve(tmpDir, subZipName); + await zip([[absolute, '/']], [], subZipPath); + subZips.push([subZipPath, subZipName]); + }) + ); + + await zip([], subZips, outputPath); + await del(tmpDir, { force: true }); + this.log.success('wrote archive to', relativeOutputPath); + } + + /** + * Cleanup the downloaded cache files, keeping the 10 newest files. Each file + * is about 25-30MB, so 10 downloads is a a decent amount of disk space for + * caches but we could potentially increase this number in the future if we like + */ + async cleanup() { + // sort archives by time desc + const archives = [...this.archives].sort((a, b) => b.time - a.time); + + // delete the 11th+ archive + for (const { sha } of archives.slice(10)) { + await this.archives.delete(sha); + } + } +} diff --git a/src/dev/typescript/ref_output_cache/repo_info.ts b/src/dev/typescript/ref_output_cache/repo_info.ts new file mode 100644 index 00000000000000..9a51f3f75182b1 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/repo_info.ts @@ -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 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 execa from 'execa'; +import { ToolingLog } from '@kbn/dev-utils'; + +export class RepoInfo { + constructor( + private readonly log: ToolingLog, + private readonly dir: string, + private readonly upstreamUrl: string + ) {} + + async getRecentShasFrom(sha: string, size: number) { + return (await this.git(['log', '--pretty=%P', `-n`, `${size}`, sha])) + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + } + + async getMergeBase(ref: string, upstreamBranch: string) { + this.log.info('ensuring we have the latest changelog from upstream', upstreamBranch); + await this.git(['fetch', this.upstreamUrl, upstreamBranch]); + + this.log.info('determining merge base with upstream'); + + const mergeBase = await this.git(['merge-base', ref, 'FETCH_HEAD']); + this.log.info('merge base with', upstreamBranch, 'is', mergeBase); + + return mergeBase; + } + + async getHeadSha() { + return await this.git(['rev-parse', 'HEAD']); + } + + getRelative(path: string) { + return Path.relative(this.dir, path); + } + + private async git(args: string[]) { + const proc = await execa('git', args, { + cwd: this.dir, + }); + + return proc.stdout.trim(); + } +} diff --git a/src/dev/typescript/ref_output_cache/zip.ts b/src/dev/typescript/ref_output_cache/zip.ts new file mode 100644 index 00000000000000..b1bd8f514bb95c --- /dev/null +++ b/src/dev/typescript/ref_output_cache/zip.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 Fs from 'fs/promises'; +import { createWriteStream } from 'fs'; +import Path from 'path'; +import { pipeline } from 'stream'; +import { promisify } from 'util'; + +import extractZip from 'extract-zip'; +import archiver from 'archiver'; + +const asyncPipeline = promisify(pipeline); + +export async function zip( + dirs: Array<[string, string]>, + files: Array<[string, string]>, + outputPath: string +) { + const archive = archiver('zip', { + zlib: { + level: 9, + }, + }); + + for (const [absolute, relative] of dirs) { + archive.directory(absolute, relative); + } + + for (const [absolute, relative] of files) { + archive.file(absolute, { + name: relative, + }); + } + + // ensure output dir exists + await Fs.mkdir(Path.dirname(outputPath), { recursive: true }); + + // await the promise from the pipeline and archive.finalize() + await Promise.all([asyncPipeline(archive, createWriteStream(outputPath)), archive.finalize()]); +} + +export async function unzip(path: string, outputDir: string) { + await extractZip(path, { + dir: outputDir, + }); +} diff --git a/src/fixtures/agg_resp/date_histogram.js b/src/fixtures/agg_resp/date_histogram.js deleted file mode 100644 index 29b34f1ce69d0e..00000000000000 --- a/src/fixtures/agg_resp/date_histogram.js +++ /dev/null @@ -1,258 +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. - */ - -export default { - took: 35, - timed_out: false, - _shards: { - total: 2, - successful: 2, - failed: 0, - }, - hits: { - total: 32899, - max_score: 0, - hits: [], - }, - aggregations: { - 1: { - buckets: [ - { - key_as_string: '2015-01-30T01:00:00.000Z', - key: 1422579600000, - doc_count: 18, - }, - { - key_as_string: '2015-01-30T02:00:00.000Z', - key: 1422583200000, - doc_count: 68, - }, - { - key_as_string: '2015-01-30T03:00:00.000Z', - key: 1422586800000, - doc_count: 146, - }, - { - key_as_string: '2015-01-30T04:00:00.000Z', - key: 1422590400000, - doc_count: 149, - }, - { - key_as_string: '2015-01-30T05:00:00.000Z', - key: 1422594000000, - doc_count: 363, - }, - { - key_as_string: '2015-01-30T06:00:00.000Z', - key: 1422597600000, - doc_count: 555, - }, - { - key_as_string: '2015-01-30T07:00:00.000Z', - key: 1422601200000, - doc_count: 878, - }, - { - key_as_string: '2015-01-30T08:00:00.000Z', - key: 1422604800000, - doc_count: 1133, - }, - { - key_as_string: '2015-01-30T09:00:00.000Z', - key: 1422608400000, - doc_count: 1438, - }, - { - key_as_string: '2015-01-30T10:00:00.000Z', - key: 1422612000000, - doc_count: 1719, - }, - { - key_as_string: '2015-01-30T11:00:00.000Z', - key: 1422615600000, - doc_count: 1813, - }, - { - key_as_string: '2015-01-30T12:00:00.000Z', - key: 1422619200000, - doc_count: 1790, - }, - { - key_as_string: '2015-01-30T13:00:00.000Z', - key: 1422622800000, - doc_count: 1582, - }, - { - key_as_string: '2015-01-30T14:00:00.000Z', - key: 1422626400000, - doc_count: 1439, - }, - { - key_as_string: '2015-01-30T15:00:00.000Z', - key: 1422630000000, - doc_count: 1154, - }, - { - key_as_string: '2015-01-30T16:00:00.000Z', - key: 1422633600000, - doc_count: 847, - }, - { - key_as_string: '2015-01-30T17:00:00.000Z', - key: 1422637200000, - doc_count: 588, - }, - { - key_as_string: '2015-01-30T18:00:00.000Z', - key: 1422640800000, - doc_count: 374, - }, - { - key_as_string: '2015-01-30T19:00:00.000Z', - key: 1422644400000, - doc_count: 152, - }, - { - key_as_string: '2015-01-30T20:00:00.000Z', - key: 1422648000000, - doc_count: 140, - }, - { - key_as_string: '2015-01-30T21:00:00.000Z', - key: 1422651600000, - doc_count: 73, - }, - { - key_as_string: '2015-01-30T22:00:00.000Z', - key: 1422655200000, - doc_count: 28, - }, - { - key_as_string: '2015-01-30T23:00:00.000Z', - key: 1422658800000, - doc_count: 9, - }, - { - key_as_string: '2015-01-31T00:00:00.000Z', - key: 1422662400000, - doc_count: 29, - }, - { - key_as_string: '2015-01-31T01:00:00.000Z', - key: 1422666000000, - doc_count: 38, - }, - { - key_as_string: '2015-01-31T02:00:00.000Z', - key: 1422669600000, - doc_count: 70, - }, - { - key_as_string: '2015-01-31T03:00:00.000Z', - key: 1422673200000, - doc_count: 136, - }, - { - key_as_string: '2015-01-31T04:00:00.000Z', - key: 1422676800000, - doc_count: 173, - }, - { - key_as_string: '2015-01-31T05:00:00.000Z', - key: 1422680400000, - doc_count: 370, - }, - { - key_as_string: '2015-01-31T06:00:00.000Z', - key: 1422684000000, - doc_count: 545, - }, - { - key_as_string: '2015-01-31T07:00:00.000Z', - key: 1422687600000, - doc_count: 845, - }, - { - key_as_string: '2015-01-31T08:00:00.000Z', - key: 1422691200000, - doc_count: 1070, - }, - { - key_as_string: '2015-01-31T09:00:00.000Z', - key: 1422694800000, - doc_count: 1419, - }, - { - key_as_string: '2015-01-31T10:00:00.000Z', - key: 1422698400000, - doc_count: 1725, - }, - { - key_as_string: '2015-01-31T11:00:00.000Z', - key: 1422702000000, - doc_count: 1801, - }, - { - key_as_string: '2015-01-31T12:00:00.000Z', - key: 1422705600000, - doc_count: 1823, - }, - { - key_as_string: '2015-01-31T13:00:00.000Z', - key: 1422709200000, - doc_count: 1657, - }, - { - key_as_string: '2015-01-31T14:00:00.000Z', - key: 1422712800000, - doc_count: 1454, - }, - { - key_as_string: '2015-01-31T15:00:00.000Z', - key: 1422716400000, - doc_count: 1131, - }, - { - key_as_string: '2015-01-31T16:00:00.000Z', - key: 1422720000000, - doc_count: 810, - }, - { - key_as_string: '2015-01-31T17:00:00.000Z', - key: 1422723600000, - doc_count: 583, - }, - { - key_as_string: '2015-01-31T18:00:00.000Z', - key: 1422727200000, - doc_count: 384, - }, - { - key_as_string: '2015-01-31T19:00:00.000Z', - key: 1422730800000, - doc_count: 165, - }, - { - key_as_string: '2015-01-31T20:00:00.000Z', - key: 1422734400000, - doc_count: 135, - }, - { - key_as_string: '2015-01-31T21:00:00.000Z', - key: 1422738000000, - doc_count: 72, - }, - { - key_as_string: '2015-01-31T22:00:00.000Z', - key: 1422741600000, - doc_count: 8, - }, - ], - }, - }, -}; diff --git a/src/fixtures/agg_resp/geohash_grid.js b/src/fixtures/agg_resp/geohash_grid.js deleted file mode 100644 index 4a8fb3704c9b3b..00000000000000 --- a/src/fixtures/agg_resp/geohash_grid.js +++ /dev/null @@ -1,84 +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 _ from 'lodash'; -export default function GeoHashGridAggResponseFixture() { - // for vis: - // - // vis = new Vis(indexPattern, { - // type: 'tile_map', - // aggs:[ - // { schema: 'metric', type: 'avg', params: { field: 'bytes' } }, - // { schema: 'split', type: 'terms', params: { field: '@tags', size: 10 } }, - // { schema: 'segment', type: 'geohash_grid', params: { field: 'geo.coordinates', precision: 3 } } - // ], - // params: { - // isDesaturated: true, - // mapType: 'Scaled%20Circle%20Markers' - // }, - // }); - - const geoHashCharts = _.union( - _.range(48, 57), // 0-9 - _.range(65, 90), // A-Z - _.range(97, 122) // a-z - ); - - const tags = _.times(_.random(4, 20), function (i) { - // random number of tags - let docCount = 0; - const buckets = _.times(_.random(40, 200), function () { - return _.sampleSize(geoHashCharts, 3).join(''); - }) - .sort() - .map(function (geoHash) { - const count = _.random(1, 5000); - - docCount += count; - - return { - key: geoHash, - doc_count: count, - 1: { - value: 2048 + i, - }, - }; - }); - - return { - key: 'tag ' + (i + 1), - doc_count: docCount, - 3: { - buckets: buckets, - }, - 1: { - value: 1000 + i, - }, - }; - }); - - return { - took: 3, - timed_out: false, - _shards: { - total: 4, - successful: 4, - failed: 0, - }, - hits: { - total: 298, - max_score: 0.0, - hits: [], - }, - aggregations: { - 2: { - buckets: tags, - }, - }, - }; -} diff --git a/src/fixtures/agg_resp/range.js b/src/fixtures/agg_resp/range.js deleted file mode 100644 index ca15f535add82d..00000000000000 --- a/src/fixtures/agg_resp/range.js +++ /dev/null @@ -1,45 +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. - */ - -export default { - took: 35, - timed_out: false, - _shards: { - total: 7, - successful: 7, - failed: 0, - }, - hits: { - total: 218512, - max_score: 0, - hits: [], - }, - aggregations: { - 1: { - buckets: { - '*-1024.0': { - to: 1024, - to_as_string: '1024.0', - doc_count: 20904, - }, - '1024.0-2560.0': { - from: 1024, - from_as_string: '1024.0', - to: 2560, - to_as_string: '2560.0', - doc_count: 23358, - }, - '2560.0-*': { - from: 2560, - from_as_string: '2560.0', - doc_count: 174250, - }, - }, - }, - }, -}; diff --git a/src/fixtures/config_upgrade_from_4.0.0.json b/src/fixtures/config_upgrade_from_4.0.0.json deleted file mode 100644 index 522de78648c9bd..00000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 1, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json b/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json deleted file mode 100644 index 8767232dcdc1c0..00000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 2, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.1-SNAPSHOT", - "_score": 1, - "_source": { - "buildNum": 5921, - "defaultIndex": "logstash-*" - } - }, - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json b/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json deleted file mode 100644 index 57b486491b397a..00000000000000 --- a/src/fixtures/config_upgrade_from_4.0.0_to_4.0.1.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "took": 1, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 2, - "max_score": 1, - "hits": [ - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.1", - "_score": 1, - "_source": { - "buildNum": 5921, - "defaultIndex": "logstash-*" - } - }, - { - "_index": ".kibana", - "_type": "config", - "_id": "4.0.0", - "_score": 1, - "_source": { - "buildNum": 5888, - "defaultIndex": "logstash-*" - } - } - ] - } -} diff --git a/src/fixtures/fake_hierarchical_data.ts b/src/fixtures/fake_hierarchical_data.ts deleted file mode 100644 index 2e23acfc3a8032..00000000000000 --- a/src/fixtures/fake_hierarchical_data.ts +++ /dev/null @@ -1,621 +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. - */ - -export const metricOnly = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_1: { value: 412032 }, - }, -}; - -export const threeTermBuckets = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_2: { - buckets: [ - { - key: 'png', - doc_count: 50, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'IT', - doc_count: 10, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 4, agg_1: { value: 0 } }, - { key: 'mac', doc_count: 6, agg_1: { value: 9299 } }, - ], - }, - }, - { - key: 'US', - doc_count: 20, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'linux', doc_count: 12, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 8, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - { - key: 'css', - doc_count: 20, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'MX', - doc_count: 7, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 3, agg_1: { value: 4992 } }, - { key: 'mac', doc_count: 4, agg_1: { value: 5892 } }, - ], - }, - }, - { - key: 'US', - doc_count: 13, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'linux', doc_count: 12, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 1, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - { - key: 'html', - doc_count: 90, - agg_1: { value: 412032 }, - agg_3: { - buckets: [ - { - key: 'CN', - doc_count: 85, - agg_1: { value: 9299 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 46, agg_1: { value: 4992 } }, - { key: 'mac', doc_count: 39, agg_1: { value: 5892 } }, - ], - }, - }, - { - key: 'FR', - doc_count: 15, - agg_1: { value: 8293 }, - agg_4: { - buckets: [ - { key: 'win', doc_count: 3, agg_1: { value: 3992 } }, - { key: 'mac', doc_count: 12, agg_1: { value: 3029 } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, -}; - -export const oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative = { - hits: { total: 1000, hits: [], max_score: 0 }, - aggregations: { - agg_3: { - buckets: [ - { - key: 'png', - doc_count: 50, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 1, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 23, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 2, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 203 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 39, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 3, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 200 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 329, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 4, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 103 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 22, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 5, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 153 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 93, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 35, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 239 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 72, - }, - }, - ], - }, - }, - }, - ], - }, - }, - { - key: 'css', - doc_count: 20, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 1, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 75, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 2, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 10 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 11, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 3, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 24 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 238, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 4, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 49 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 343, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 5, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 100 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 837, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 5, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 23 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 302, - }, - }, - ], - }, - }, - }, - ], - }, - }, - { - key: 'html', - doc_count: 90, - agg_4: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 10, - agg_1: { value: 9283 }, - agg_2: { value: 1411862400000 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 30, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 20, - agg_1: { value: 28349 }, - agg_2: { value: 1411948800000 }, - agg_5: { value: 1 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 43, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 30, - agg_1: { value: 84330 }, - agg_2: { value: 1412035200000 }, - agg_5: { value: 5 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 88, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 11, - agg_1: { value: 34992 }, - agg_2: { value: 1412121600000 }, - agg_5: { value: 10 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 91, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 12, - agg_1: { value: 145432 }, - agg_2: { value: 1412208000000 }, - agg_5: { value: 43 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 534, - }, - }, - ], - }, - }, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 7, - agg_1: { value: 220943 }, - agg_2: { value: 1412294400000 }, - agg_5: { value: 1 }, - agg_6: { - hits: { - total: 2, - hits: [ - { - fields: { - bytes: 553, - }, - }, - ], - }, - }, - }, - ], - }, - }, - ], - }, - }, -}; - -export const oneRangeBucket = { - took: 35, - timed_out: false, - _shards: { - total: 1, - successful: 1, - failed: 0, - }, - hits: { - total: 6039, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: { - '0.0-1000.0': { - from: 0, - from_as_string: '0.0', - to: 1000, - to_as_string: '1000.0', - doc_count: 606, - }, - '1000.0-2000.0': { - from: 1000, - from_as_string: '1000.0', - to: 2000, - to_as_string: '2000.0', - doc_count: 298, - }, - }, - }, - }, -}; - -export const oneFilterBucket = { - took: 11, - timed_out: false, - _shards: { - total: 1, - successful: 1, - failed: 0, - }, - hits: { - total: 6005, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: { - 'type:apache': { - doc_count: 4844, - }, - 'type:nginx': { - doc_count: 1161, - }, - }, - }, - }, -}; - -export const oneHistogramBucket = { - took: 37, - timed_out: false, - _shards: { - total: 6, - successful: 6, - failed: 0, - }, - hits: { - total: 49208, - max_score: 0, - hits: [], - }, - aggregations: { - agg_2: { - buckets: [ - { - key_as_string: '2014-09-28T00:00:00.000Z', - key: 1411862400000, - doc_count: 8247, - }, - { - key_as_string: '2014-09-29T00:00:00.000Z', - key: 1411948800000, - doc_count: 8184, - }, - { - key_as_string: '2014-09-30T00:00:00.000Z', - key: 1412035200000, - doc_count: 8269, - }, - { - key_as_string: '2014-10-01T00:00:00.000Z', - key: 1412121600000, - doc_count: 8141, - }, - { - key_as_string: '2014-10-02T00:00:00.000Z', - key: 1412208000000, - doc_count: 8148, - }, - { - key_as_string: '2014-10-03T00:00:00.000Z', - key: 1412294400000, - doc_count: 8219, - }, - ], - }, - }, -}; diff --git a/src/fixtures/field_mapping.js b/src/fixtures/field_mapping.js deleted file mode 100644 index 5077e361d5458d..00000000000000 --- a/src/fixtures/field_mapping.js +++ /dev/null @@ -1,68 +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. - */ - -export default { - test: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'long', - }, - }, - }, - 'foo.bar': { - full_name: 'foo.bar', - mapping: { - bar: { - type: 'string', - }, - }, - }, - not_analyzed_field: { - full_name: 'not_analyzed_field', - mapping: { - bar: { - type: 'string', - index: 'not_analyzed', - }, - }, - }, - index_no_field: { - full_name: 'index_no_field', - mapping: { - bar: { - type: 'string', - index: 'no', - }, - }, - }, - _id: { - full_name: '_id', - mapping: { - _id: { - store: false, - index: 'no', - }, - }, - }, - _timestamp: { - full_name: '_timestamp', - mapping: { - _timestamp: { - store: true, - index: 'no', - }, - }, - }, - }, - }, - }, -}; diff --git a/src/fixtures/hits.js b/src/fixtures/hits.js deleted file mode 100644 index af8264e320909b..00000000000000 --- a/src/fixtures/hits.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 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. - */ - -export default function fitsFixture() { - return [ - // extension - // | machine.os - //timestamp | | bytes - //| ssl ip | | | request - [0, true, '192.168.0.1', 'php', 'Linux', 10, 'foo'], - [1, true, '192.168.0.1', 'php', 'Linux', 20, 'bar'], - [2, true, '192.168.0.1', 'php', 'Linux', 30, 'bar'], - [3, true, '192.168.0.1', 'php', 'Linux', 30, 'baz'], - [4, true, '192.168.0.1', 'php', 'Linux', 30, 'baz'], - [5, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [6, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [7, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [8, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - [9, true, '192.168.0.1', 'php', 'Linux', 40.141592, 'bat'], - ].map((row, i) => { - return { - _score: 1, - _id: 1000 + i, - _index: 'test-index', - _source: { - '@timestamp': row[0], - ssl: row[1], - ip: row[2], - extension: row[3], - 'machine.os': row[4], - bytes: row[5], - request: row[6], - }, - }; - }); -} diff --git a/src/fixtures/mapping_with_dupes.js b/src/fixtures/mapping_with_dupes.js deleted file mode 100644 index 7f6da2600c9a81..00000000000000 --- a/src/fixtures/mapping_with_dupes.js +++ /dev/null @@ -1,46 +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. - */ - -export default { - test: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'long', - }, - }, - }, - 'foo.bar': { - full_name: 'foo.bar', - mapping: { - bar: { - type: 'string', - }, - }, - }, - }, - }, - }, - duplicates: { - mappings: { - testType: { - baz: { - full_name: 'baz', - mapping: { - bar: { - type: 'date', - }, - }, - }, - }, - }, - }, -}; diff --git a/src/fixtures/mock_index_patterns.js b/src/fixtures/mock_index_patterns.js deleted file mode 100644 index ce44b71613b010..00000000000000 --- a/src/fixtures/mock_index_patterns.js +++ /dev/null @@ -1,19 +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 sinon from 'sinon'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -export default function (Private) { - const indexPatterns = Private(FixturesStubbedLogstashIndexPatternProvider); - const getIndexPatternStub = sinon.stub().resolves(indexPatterns); - - return { - get: getIndexPatternStub, - }; -} diff --git a/src/fixtures/mock_ui_state.js b/src/fixtures/mock_ui_state.js deleted file mode 100644 index fc0a18137a5fde..00000000000000 --- a/src/fixtures/mock_ui_state.js +++ /dev/null @@ -1,33 +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 { set } from '@elastic/safer-lodash-set'; -import _ from 'lodash'; -let values = {}; -export default { - get: function (path, def) { - return _.get(values, path, def); - }, - set: function (path, val) { - set(values, path, val); - return val; - }, - setSilent: function (path, val) { - set(values, path, val); - return val; - }, - emit: _.noop, - on: _.noop, - off: _.noop, - clearAllKeys: function () { - values = {}; - }, - _reset: function () { - values = {}; - }, -}; diff --git a/src/fixtures/stubbed_search_source.js b/src/fixtures/stubbed_search_source.js deleted file mode 100644 index ea41e7bbe681c8..00000000000000 --- a/src/fixtures/stubbed_search_source.js +++ /dev/null @@ -1,54 +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 sinon from 'sinon'; -import searchResponse from 'fixtures/search_response'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -export default function stubSearchSource(Private, $q, Promise) { - let deferedResult = $q.defer(); - const indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - - let onResultsCount = 0; - return { - setField: sinon.spy(), - fetch: sinon.spy(), - destroy: sinon.spy(), - getField: function (param) { - switch (param) { - case 'index': - return indexPattern; - default: - throw new Error(`Param "${param}" is not implemented in the stubbed search source`); - } - }, - crankResults: function () { - deferedResult.resolve(searchResponse); - deferedResult = $q.defer(); - }, - onResults: function () { - onResultsCount++; - - // Up to the test to resolve this manually - // For example: - // someHandler.resolve(require('fixtures/search_response')) - return deferedResult.promise; - }, - getOnResultsCount: function () { - return onResultsCount; - }, - _flatten: function () { - return Promise.resolve({ index: indexPattern, body: {} }); - }, - _requestStartHandlers: [], - onRequestStart(fn) { - this._requestStartHandlers.push(fn); - }, - requestIsStopped() {}, - }; -} diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index 0be582a4c0294d..c7a8c0a6135c7a 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -12,7 +12,7 @@ import { UnregisterCallback } from 'history'; import { parse } from 'query-string'; import { UiCounterMetricType } from '@kbn/analytics'; -import { Comparators, EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui'; import { IUiSettingsClient, @@ -28,7 +28,7 @@ import { Form } from './components/form'; import { AdvancedSettingsVoiceAnnouncement } from './components/advanced_settings_voice_announcement'; import { ComponentRegistry } from '../'; -import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib'; +import { getAriaName, toEditableConfig, fieldSorter, DEFAULT_CATEGORY } from './lib'; import { FieldSetting, SettingsChanges } from './types'; import { parseErrorMsg } from './components/search/search'; @@ -185,17 +185,17 @@ export class AdvancedSettings extends Component { + .map(([settingId, settingDef]) => { return toEditableConfig({ - def: setting[1], - name: setting[0], - value: setting[1].userValue, - isCustom: config.isCustom(setting[0]), - isOverridden: config.isOverridden(setting[0]), + def: settingDef, + name: settingId, + value: settingDef.userValue, + isCustom: config.isCustom(settingId), + isOverridden: config.isOverridden(settingId), }); }) - .filter((c) => !c.readonly) - .sort(Comparators.property('name', Comparators.default('asc'))); + .filter((c) => !c.readOnly) + .sort(fieldSorter); } mapSettings(settings: FieldSetting[]) { diff --git a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap index 19bf9e6d73757b..517a6238c25191 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap +++ b/src/plugins/advanced_settings/public/management_app/components/field/__snapshots__/field.test.tsx.snap @@ -866,6 +866,419 @@ exports[`Field for boolean setting should render user value if there is user val `; +exports[`Field for color setting should render as read only if saving is disabled 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + +`; + +exports[`Field for color setting should render as read only with help text if overridden 1`] = ` + +
+ + + + + + null + , + } + } + /> + + + + + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + } + label="color:test:setting" + labelType="label" + > + + + +`; + +exports[`Field for color setting should render custom setting icon if it is custom 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + } + type="asterisk" + /> + +

+ } +> + + + + +`; + +exports[`Field for color setting should render default value if there is no user value set 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + +`; + +exports[`Field for color setting should render unsaved value if there are unsaved changes 1`] = ` + +
+ + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + } + type="asterisk" + /> + +

+ } +> + + + +

+ Setting is currently not saved. +

+
+
+ +`; + +exports[`Field for color setting should render user value if there is user value is set 1`] = ` + +
+ + + + + + null + , + } + } + /> + + + + + } + fullWidth={true} + id="color:test:setting-group" + title={ +

+ + Color test setting + + + +

+ } +> + + + + + +     + + + } + label="color:test:setting" + labelType="label" + > + + + +`; + exports[`Field for image setting should render as read only if saving is disabled 1`] = ` = { isOverridden: false, ...defaults, }, + color: { + name: 'color:test:setting', + ariaName: 'color test setting', + displayName: 'Color test setting', + description: 'Description for Color test setting', + type: 'color', + value: undefined, + defVal: null, + isCustom: false, + isOverridden: false, + ...defaults, + }, }; const userValues = { array: ['user', 'value'], @@ -174,6 +187,7 @@ const userValues = { select: 'banana', string: 'foo', stringWithValidation: 'fooUserValue', + color: '#FACF0C', }; const invalidUserValues = { @@ -187,6 +201,8 @@ const getFieldSettingValue = (wrapper: ReactWrapper, name: string, type: string) const field = findTestSubject(wrapper, `advancedSetting-editField-${name}`); if (type === 'boolean') { return field.props()['aria-checked']; + } else if (type === 'color') { + return field.props().color; } else { return field.props().value; } @@ -423,6 +439,36 @@ describe('Field', () => { }); } }); + } else if (type === 'color') { + describe(`for changing ${type} setting`, () => { + const { wrapper, component } = setup(); + const userValue = userValues[type]; + + it('should be able to change value', async () => { + await (component.instance() as Field).onFieldChange(userValue); + const updated = wrapper.update(); + expect(handleChange).toBeCalledWith(setting.name, { value: userValue }); + updated.setProps({ unsavedChanges: { value: userValue } }); + const currentValue = wrapper.find('EuiColorPicker').prop('color'); + expect(currentValue).toEqual(userValue); + }); + + it('should be able to reset to default value', async () => { + await wrapper.setProps({ + unsavedChanges: {}, + setting: { ...setting, value: userValue }, + }); + const updated = wrapper.update(); + findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click'); + const expectedEditableValue = getEditableValue(setting.type, setting.defVal); + expect(handleChange).toBeCalledWith(setting.name, { + value: expectedEditableValue, + }); + updated.setProps({ unsavedChanges: { value: expectedEditableValue } }); + const currentValue = wrapper.find('EuiColorPicker').prop('color'); + expect(currentValue).toEqual(expectedEditableValue); + }); + }); } else { describe(`for changing ${type} setting`, () => { const { wrapper, component } = setup(); diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index 5569a6e11872a0..f5db5c3e371b39 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -17,6 +17,7 @@ import { EuiBadge, EuiCode, EuiCodeBlock, + EuiColorPicker, EuiScreenReaderOnly, EuiCodeEditor, EuiDescribedFormGroup, @@ -392,6 +393,17 @@ export class Field extends PureComponent { data-test-subj={`advancedSetting-editField-${name}`} /> ); + case 'color': + return ( + + ); default: return ( ): FieldSetting => ({ + displayName: 'displayName', + name: 'field', + value: 'value', + requiresPageReload: false, + type: 'string', + category: [], + ariaName: 'ariaName', + isOverridden: false, + defVal: 'defVal', + isCustom: false, + ...parts, +}); + +describe('fieldSorter', () => { + it('sort fields based on their `order` field if present on both', () => { + const fieldA = createField({ order: 3 }); + const fieldB = createField({ order: 1 }); + const fieldC = createField({ order: 2 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]); + }); + it('fields with order defined are ordered first', () => { + const fieldA = createField({ order: 2 }); + const fieldB = createField({ order: undefined }); + const fieldC = createField({ order: 1 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldA, fieldB]); + }); + it('sorts by `name` when fields have the same `order`', () => { + const fieldA = createField({ order: 2, name: 'B' }); + const fieldB = createField({ order: 1 }); + const fieldC = createField({ order: 2, name: 'A' }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]); + }); + + it('sorts by `name` when fields have no `order`', () => { + const fieldA = createField({ order: undefined, name: 'B' }); + const fieldB = createField({ order: undefined, name: 'A' }); + const fieldC = createField({ order: 1 }); + + expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldB, fieldA]); + }); +}); diff --git a/src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts b/src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts new file mode 100644 index 00000000000000..90bfa18d2198e6 --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/lib/sort_fields.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Comparators } from '@elastic/eui'; +import { FieldSetting } from '../types'; + +const cmp = Comparators.default('asc'); + +export const fieldSorter = (a: FieldSetting, b: FieldSetting): number => { + const aOrder = a.order !== undefined; + const bOrder = b.order !== undefined; + + if (aOrder && bOrder) { + if (a.order === b.order) { + return cmp(a.name, b.name); + } + return cmp(a.order, b.order); + } + if (aOrder) { + return -1; + } + if (bOrder) { + return 1; + } + return cmp(a.name, b.name); +}; diff --git a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts index b2b7f1c1016cdb..49abe3b279a28c 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts @@ -12,6 +12,7 @@ import { StringValidationRegexString, SavedObjectAttribute, } from 'src/core/public'; +import { FieldSetting } from '../types'; import { getValType } from './get_val_type'; import { getAriaName } from './get_aria_name'; import { DEFAULT_CATEGORY } from './default_category'; @@ -41,7 +42,7 @@ export function toEditableConfig({ const validationTyped = def.validation as StringValidationRegexString; - const conf = { + const conf: FieldSetting = { name, displayName: def.name || name, ariaName: def.name || getAriaName(name), @@ -49,7 +50,7 @@ export function toEditableConfig({ category: def.category && def.category.length ? def.category : [DEFAULT_CATEGORY], isCustom, isOverridden, - readonly: !!def.readonly, + readOnly: !!def.readonly, defVal: def.value, type: getValType(def, value), description: def.description, @@ -63,6 +64,7 @@ export function toEditableConfig({ : def.validation, options: def.options, optionLabels: def.optionLabels, + order: def.order, requiresPageReload: !!def.requiresPageReload, metric: def.metric, }; diff --git a/src/plugins/advanced_settings/public/management_app/types.ts b/src/plugins/advanced_settings/public/management_app/types.ts index 0563fa310bc77a..50b39114d2143e 100644 --- a/src/plugins/advanced_settings/public/management_app/types.ts +++ b/src/plugins/advanced_settings/public/management_app/types.ts @@ -25,6 +25,7 @@ export interface FieldSetting { isCustom: boolean; validation?: StringValidation | ImageValidation; readOnly?: boolean; + order?: number; deprecation?: { message: string; docLinksKey: string; diff --git a/src/plugins/apm_oss/server/index.ts b/src/plugins/apm_oss/server/index.ts index ae7b70339cabcf..bea9965748f27a 100644 --- a/src/plugins/apm_oss/server/index.ts +++ b/src/plugins/apm_oss/server/index.ts @@ -21,6 +21,7 @@ export const config = { sourcemapIndices: schema.string({ defaultValue: 'apm-*' }), onboardingIndices: schema.string({ defaultValue: 'apm-*' }), indexPattern: schema.string({ defaultValue: 'apm-*' }), + fleetMode: schema.boolean({ defaultValue: true }), }), }; diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index e074d529917d21..8286a4badcbe5d 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -10,7 +10,8 @@ "savedObjects", "share", "uiActions", - "urlForwarding" + "urlForwarding", + "presentationUtil" ], "optionalPlugins": [ "home", diff --git a/src/plugins/dashboard/public/application/actions/copy_to_dashboard_action.tsx b/src/plugins/dashboard/public/application/actions/copy_to_dashboard_action.tsx new file mode 100644 index 00000000000000..f16486dd65e3c8 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/copy_to_dashboard_action.tsx @@ -0,0 +1,90 @@ +/* + * 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 React from 'react'; +import { OverlayStart } from '../../../../../core/public'; +import { dashboardCopyToDashboardAction } from '../../dashboard_strings'; +import { EmbeddableStateTransfer, IEmbeddable } from '../../services/embeddable'; +import { toMountPoint } from '../../services/kibana_react'; +import { PresentationUtilPluginStart } from '../../services/presentation_util'; +import { Action, IncompatibleActionError } from '../../services/ui_actions'; +import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable'; +import { CopyToDashboardModal } from './copy_to_dashboard_modal'; + +export const ACTION_COPY_TO_DASHBOARD = 'copyToDashboard'; + +export interface CopyToDashboardActionContext { + embeddable: IEmbeddable; +} + +export interface DashboardCopyToCapabilities { + canCreateNew: boolean; + canEditExisting: boolean; +} + +function isDashboard(embeddable: IEmbeddable): embeddable is DashboardContainer { + return embeddable.type === DASHBOARD_CONTAINER_TYPE; +} + +export class CopyToDashboardAction implements Action { + public readonly type = ACTION_COPY_TO_DASHBOARD; + public readonly id = ACTION_COPY_TO_DASHBOARD; + public order = 1; + + constructor( + private overlays: OverlayStart, + private stateTransfer: EmbeddableStateTransfer, + private capabilities: DashboardCopyToCapabilities, + private PresentationUtilContext: PresentationUtilPluginStart['ContextProvider'] + ) {} + + public getDisplayName({ embeddable }: CopyToDashboardActionContext) { + if (!embeddable.parent || !isDashboard(embeddable.parent)) { + throw new IncompatibleActionError(); + } + + return dashboardCopyToDashboardAction.getDisplayName(); + } + + public getIconType({ embeddable }: CopyToDashboardActionContext) { + if (!embeddable.parent || !isDashboard(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return 'exit'; + } + + public async isCompatible({ embeddable }: CopyToDashboardActionContext) { + return Boolean( + embeddable.parent && + isDashboard(embeddable.parent) && + (this.capabilities.canCreateNew || this.capabilities.canEditExisting) + ); + } + + public async execute({ embeddable }: CopyToDashboardActionContext) { + if (!embeddable.parent || !isDashboard(embeddable.parent)) { + throw new IncompatibleActionError(); + } + const session = this.overlays.openModal( + toMountPoint( + session.close()} + stateTransfer={this.stateTransfer} + capabilities={this.capabilities} + dashboardId={(embeddable.parent as DashboardContainer).getInput().id} + embeddable={embeddable} + /> + ), + { + maxWidth: 400, + 'data-test-subj': 'copyToDashboardPanel', + } + ); + } +} 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 new file mode 100644 index 00000000000000..b16c0f5d346631 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/copy_to_dashboard_modal.tsx @@ -0,0 +1,139 @@ +/* + * 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 React, { useCallback, useState } from 'react'; +import { omit } from 'lodash'; +import { + EuiButton, + EuiButtonEmpty, + EuiFormRow, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiPanel, + EuiRadio, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { DashboardCopyToCapabilities } from './copy_to_dashboard_action'; +import { DashboardPicker } from '../../services/presentation_util'; +import { dashboardCopyToDashboardAction } from '../../dashboard_strings'; +import { EmbeddableStateTransfer, IEmbeddable } from '../../services/embeddable'; +import { createDashboardEditUrl, DashboardConstants } from '../..'; + +interface CopyToDashboardModalProps { + capabilities: DashboardCopyToCapabilities; + stateTransfer: EmbeddableStateTransfer; + PresentationUtilContext: React.FC; + embeddable: IEmbeddable; + dashboardId?: string; + closeModal: () => void; +} + +export function CopyToDashboardModal({ + PresentationUtilContext, + stateTransfer, + capabilities, + dashboardId, + embeddable, + closeModal, +}: CopyToDashboardModalProps) { + const [dashboardOption, setDashboardOption] = useState<'new' | 'existing'>('existing'); + const [selectedDashboard, setSelectedDashboard] = useState<{ id: string; name: string } | null>( + null + ); + + const onSubmit = useCallback(() => { + const state = { + input: omit(embeddable.getInput(), 'id'), + type: embeddable.type, + }; + + const path = + dashboardOption === 'existing' && selectedDashboard + ? `#${createDashboardEditUrl(selectedDashboard.id, true)}` + : `#${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`; + + closeModal(); + stateTransfer.navigateToWithEmbeddablePackage('dashboards', { + state, + path, + }); + }, [dashboardOption, embeddable, selectedDashboard, stateTransfer, closeModal]); + + return ( + + + {dashboardCopyToDashboardAction.getDisplayName()} + + + + <> + +

{dashboardCopyToDashboardAction.getDescription()}

+
+ + + +
+ {capabilities.canEditExisting && ( + <> + setDashboardOption('existing')} + /> +
+ setSelectedDashboard(dashboard)} + /> +
+ + + )} + {capabilities.canCreateNew && ( + <> + setDashboardOption('new')} + /> + + + )} +
+
+
+ +
+ + + closeModal()}> + {dashboardCopyToDashboardAction.getCancelButtonName()} + + + {dashboardCopyToDashboardAction.getAcceptButtonName()} + + +
+ ); +} diff --git a/src/plugins/dashboard/public/application/actions/index.ts b/src/plugins/dashboard/public/application/actions/index.ts index ce858d0bb7970d..827ae5bcb4419b 100644 --- a/src/plugins/dashboard/public/application/actions/index.ts +++ b/src/plugins/dashboard/public/application/actions/index.ts @@ -31,6 +31,11 @@ export { UnlinkFromLibraryActionContext, ACTION_UNLINK_FROM_LIBRARY, } from './unlink_from_library_action'; +export { + CopyToDashboardAction, + CopyToDashboardActionContext, + ACTION_COPY_TO_DASHBOARD, +} from './copy_to_dashboard_action'; export { LibraryNotificationActionContext, LibraryNotificationAction, diff --git a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx b/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx index 4e17fa1f62c148..41b27b4fd69260 100644 --- a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx +++ b/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx @@ -9,7 +9,6 @@ import { EuiButton, EuiButtonEmpty, - EuiModal, EuiModalBody, EuiModalFooter, EuiModalHeader, @@ -48,7 +47,7 @@ export const confirmCreateWithUnsaved = ( ) => { const session = overlays.openModal( toMountPoint( - session.close()}> + <> {createConfirmStrings.getCreateTitle()} @@ -85,7 +84,7 @@ export const confirmCreateWithUnsaved = ( {createConfirmStrings.getContinueButtonText()} - + ), { 'data-test-subj': 'dashboardCreateConfirmModal', 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 0caaac6764bbe9..786afc81c400cd 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 @@ -321,6 +321,33 @@ export function DashboardTopNav({ dashboardStateManager, ]); + const runQuickSave = useCallback(async () => { + const currentTitle = dashboardStateManager.getTitle(); + const currentDescription = dashboardStateManager.getDescription(); + const currentTimeRestore = dashboardStateManager.getTimeRestore(); + + let currentTags: string[] = []; + if (savedObjectsTagging) { + const dashboard = dashboardStateManager.savedDashboard; + if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) { + currentTags = dashboard.getTags(); + } + } + + save({}).then((response: SaveResult) => { + // If the save wasn't successful, put the original values back. + if (!(response as { id: string }).id) { + dashboardStateManager.setTitle(currentTitle); + dashboardStateManager.setDescription(currentDescription); + dashboardStateManager.setTimeRestore(currentTimeRestore); + if (savedObjectsTagging) { + dashboardStateManager.setTags(currentTags); + } + } + return response; + }); + }, [save, savedObjectsTagging, dashboardStateManager]); + const runClone = useCallback(() => { const currentTitle = dashboardStateManager.getTitle(); const onClone = async ( @@ -356,9 +383,8 @@ export function DashboardTopNav({ [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), [TopNavIds.DISCARD_CHANGES]: onDiscardChanges, [TopNavIds.SAVE]: runSave, + [TopNavIds.QUICK_SAVE]: runQuickSave, [TopNavIds.CLONE]: runClone, - [TopNavIds.ADD_EXISTING]: addFromLibrary, - [TopNavIds.VISUALIZE]: createNew, [TopNavIds.OPTIONS]: (anchorElement) => { showOptionsPopover({ anchorElement, @@ -394,10 +420,9 @@ export function DashboardTopNav({ onDiscardChanges, onChangeViewMode, savedDashboard, - addFromLibrary, - createNew, runClone, runSave, + runQuickSave, share, ]); @@ -419,11 +444,11 @@ export function DashboardTopNav({ const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); const showSearchBar = showQueryBar || showFilterBar; - const topNav = getTopNavConfig( - viewMode, - dashboardTopNavActions, - dashboardCapabilities.hideWriteControls - ); + const topNav = getTopNavConfig(viewMode, dashboardTopNavActions, { + hideWriteControls: dashboardCapabilities.hideWriteControls, + isNewDashboard: !savedDashboard.id, + isDirty: dashboardStateManager.isDirty, + }); return { appName: 'dashboard', diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index 37414cb948d5ae..abc128369017c5 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -20,11 +20,11 @@ import { NavAction } from '../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean + options: { hideWriteControls: boolean; isNewDashboard: boolean; isDirty: boolean } ) { switch (dashboardMode) { case ViewMode.VIEW: - return hideWriteControls + return options.hideWriteControls ? [ getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]), getShareConfig(actions[TopNavIds.SHARE]), @@ -36,20 +36,39 @@ export function getTopNavConfig( getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE]), ]; case ViewMode.EDIT: - return [ - getOptionsConfig(actions[TopNavIds.OPTIONS]), - getShareConfig(actions[TopNavIds.SHARE]), - getAddConfig(actions[TopNavIds.ADD_EXISTING]), - getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), - getSaveConfig(actions[TopNavIds.SAVE]), - getCreateNewConfig(actions[TopNavIds.VISUALIZE]), - ]; + return options.isNewDashboard + ? [ + getOptionsConfig(actions[TopNavIds.OPTIONS]), + getShareConfig(actions[TopNavIds.SHARE]), + getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), + getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), + getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard), + ] + : [ + getOptionsConfig(actions[TopNavIds.OPTIONS]), + getShareConfig(actions[TopNavIds.SHARE]), + getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), + getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), + getSaveConfig(actions[TopNavIds.SAVE]), + getQuickSave(actions[TopNavIds.QUICK_SAVE]), + ]; default: return []; } } +function getSaveButtonLabel() { + return i18n.translate('dashboard.topNave.saveButtonAriaLabel', { + defaultMessage: 'save', + }); +} + +function getSaveAsButtonLabel() { + return i18n.translate('dashboard.topNave.saveAsButtonAriaLabel', { + defaultMessage: 'save as', + }); +} + function getFullScreenConfig(action: NavAction) { return { id: 'full-screen', @@ -89,17 +108,32 @@ function getEditConfig(action: NavAction) { /** * @returns {kbnTopNavConfig} */ -function getSaveConfig(action: NavAction) { +function getQuickSave(action: NavAction) { return { - id: 'save', - label: i18n.translate('dashboard.topNave.saveButtonAriaLabel', { - defaultMessage: 'save', - }), + id: 'quick-save', + emphasize: true, + label: getSaveButtonLabel(), description: i18n.translate('dashboard.topNave.saveConfigDescription', { - defaultMessage: 'Save your dashboard', + defaultMessage: 'Quick save your dashboard without any prompts', + }), + testId: 'dashboardQuickSaveMenuItem', + run: action, + }; +} + +/** + * @returns {kbnTopNavConfig} + */ +function getSaveConfig(action: NavAction, isNewDashboard = false) { + return { + id: 'save', + label: isNewDashboard ? getSaveButtonLabel() : getSaveAsButtonLabel(), + description: i18n.translate('dashboard.topNave.saveAsConfigDescription', { + defaultMessage: 'Save as a new dashboard', }), testId: 'dashboardSaveMenuItem', run: action, + emphasize: isNewDashboard, }; } @@ -157,42 +191,6 @@ function getCloneConfig(action: NavAction) { /** * @returns {kbnTopNavConfig} */ -function getAddConfig(action: NavAction) { - return { - id: 'add', - label: i18n.translate('dashboard.topNave.addButtonAriaLabel', { - defaultMessage: 'Library', - }), - description: i18n.translate('dashboard.topNave.addConfigDescription', { - defaultMessage: 'Add an existing visualization to the dashboard', - }), - testId: 'dashboardAddPanelButton', - run: action, - }; -} - -/** - * @returns {kbnTopNavConfig} - */ -function getCreateNewConfig(action: NavAction) { - return { - emphasize: true, - iconType: 'plusInCircleFilled', - id: 'addNew', - label: i18n.translate('dashboard.topNave.addNewButtonAriaLabel', { - defaultMessage: 'Create panel', - }), - description: i18n.translate('dashboard.topNave.addNewConfigDescription', { - defaultMessage: 'Create a new panel on this dashboard', - }), - testId: 'dashboardAddNewPanelButton', - run: action, - }; -} - -// /** -// * @returns {kbnTopNavConfig} -// */ function getShareConfig(action: NavAction | undefined) { return { id: 'share', diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot index f822a7e70d523c..afbbecb3935e0e 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot +++ b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot @@ -10,7 +10,7 @@ exports[`Storyshots components/PanelToolbar default 1`] = ` > + +
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details view in the Details Panel when the panelView is eventDetail and the eventId is set 1`] = `null`; + +exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set 1`] = ` +Array [ + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + + + + + +
+
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
, + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + + + + +
+
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
, + .c1 { + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.c2 .euiFlyoutBody__overflow { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; +} + +.c2 .euiFlyoutBody__overflow .euiFlyoutBody__overflowContent { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: hidden; + padding: 4px 16px 64px; +} + +.c0 { + z-index: 7000; +} + +
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
, +] +`; + +exports[`Details Panel Component DetailsPanel:HostDetails: rendering it should render the Host Details view in the Details Panel when the panelView is hostDetail and the hostName is set 1`] = `null`; + +exports[`Details Panel Component DetailsPanel:NetworkDetails: rendering it should render the Network Details view in the Details Panel when the panelView is networkDetail and the ip is set 1`] = `null`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx rename to x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 159745c5a3f861..6e8238dfe4b25e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -21,7 +21,7 @@ import { import React, { useMemo, useState } from 'react'; import styled from 'styled-components'; -import { TimelineExpandedEventType, TimelineTabs } from '../../../../../common/types/timeline'; +import { TimelineTabs } from '../../../../../common/types/timeline'; import { BrowserFields } from '../../../../common/containers/source'; import { EventDetails, @@ -36,7 +36,7 @@ export type HandleOnEventClosed = () => void; interface Props { browserFields: BrowserFields; detailsData: TimelineEventsDetailsItem[] | null; - event: TimelineExpandedEventType; + event: { eventId: string; indexName: string }; isAlert: boolean; loading: boolean; messageHeight?: number; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx new file mode 100644 index 00000000000000..d8b9e7121f60db --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -0,0 +1,109 @@ +/* + * 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 { some } from 'lodash/fp'; +import { EuiFlyoutHeader, EuiFlyoutBody, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; + +import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; +import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; +import { useTimelineEventsDetails } from '../../../containers/details'; +import { TimelineTabs } from '../../../../../common/types/timeline'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow: hidden; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow: hidden; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +interface EventDetailsPanelProps { + browserFields: BrowserFields; + docValueFields: DocValueFields[]; + expandedEvent: { eventId: string; indexName: string }; + handleOnEventClosed: () => void; + isFlyoutView?: boolean; + tabType: TimelineTabs; + timelineId: string; +} + +const EventDetailsPanelComponent: React.FC = ({ + browserFields, + docValueFields, + expandedEvent, + handleOnEventClosed, + isFlyoutView, + tabType, + timelineId, +}) => { + const [loading, detailsData] = useTimelineEventsDetails({ + docValueFields, + indexName: expandedEvent.indexName ?? '', + eventId: expandedEvent.eventId ?? '', + skip: !expandedEvent.eventId, + }); + + const isAlert = some({ category: 'signal', field: 'signal.rule.id' }, detailsData); + + if (!expandedEvent?.eventId) { + return null; + } + + return isFlyoutView ? ( + <> + + + + + + + + ) : ( + <> + + + + + ); +}; + +export const EventDetailsPanel = React.memo( + EventDetailsPanelComponent, + (prevProps, nextProps) => + deepEqual(prevProps.browserFields, nextProps.browserFields) && + deepEqual(prevProps.docValueFields, nextProps.docValueFields) && + prevProps.timelineId === nextProps.timelineId +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts similarity index 77% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx rename to x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts index 234f3ac49e64d0..2910e04747e397 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/translations.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts @@ -14,13 +14,6 @@ export const MESSAGE = i18n.translate( } ); -export const COPY_TO_CLIPBOARD = i18n.translate( - 'xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip', - { - defaultMessage: 'Copy to Clipboard', - } -); - export const CLOSE = i18n.translate( 'xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel', { @@ -28,13 +21,6 @@ export const CLOSE = i18n.translate( } ); -export const EVENT = i18n.translate( - 'xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle', - { - defaultMessage: 'Event', - } -); - export const EVENT_DETAILS_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.timeline.expandableEvent.placeholder', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx new file mode 100644 index 00000000000000..4e101e29bb4841 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/expandable_host.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiTitle } from '@elastic/eui'; +import { HostDetailsLink } from '../../../../common/components/links'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { HostOverview } from '../../../../overview/components/host_overview'; +import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; +import { HostItem } from '../../../../../common/search_strategy'; +import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider'; +import { hostToCriteria } from '../../../../common/components/ml/criteria/host_to_criteria'; +import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; +import { HostOverviewByNameQuery } from '../../../../hosts/containers/hosts/details'; + +interface ExpandableHostProps { + hostName: string; +} + +export const ExpandableHostDetailsTitle = ({ hostName }: ExpandableHostProps) => ( + +

+ {i18n.translate('xpack.securitySolution.timeline.sidePanel.hostDetails.title', { + defaultMessage: 'Host details', + })} + {`: ${hostName}`} +

+
+); + +export const ExpandableHostDetailsPageLink = ({ hostName }: ExpandableHostProps) => ( + + {i18n.translate('xpack.securitySolution.timeline.sidePanel.hostDetails.hostDetailsPageLink', { + defaultMessage: 'View details page', + })} + +); + +export const ExpandableHostDetails = ({ + contextID, + hostName, +}: ExpandableHostProps & { contextID: string }) => { + const { to, from, isInitializing } = useGlobalTime(); + const { docValueFields, selectedPatterns } = useSourcererScope(); + return ( + + {({ hostOverview, loading, id }) => ( + + {({ isLoadingAnomaliesData, anomaliesData }) => ( + { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }} + /> + )} + + )} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx new file mode 100644 index 00000000000000..39064cda160011 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/host_details/index.tsx @@ -0,0 +1,116 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import { + EuiFlexGroup, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { + ExpandableHostDetails, + ExpandableHostDetailsPageLink, + ExpandableHostDetailsTitle, +} from './expandable_host'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + &.euiFlexItem { + flex: 1 0 0; + overflow-y: scroll; + overflow-x: hidden; + } +`; + +const StyledEuiFlexButtonWrapper = styled(EuiFlexItem)` + align-self: flex-start; +`; + +interface HostDetailsProps { + contextID: string; + expandedHost: { hostName: string }; + handleOnHostClosed: () => void; + isFlyoutView?: boolean; +} + +export const HostDetailsPanel: React.FC = React.memo( + ({ contextID, expandedHost, handleOnHostClosed, isFlyoutView }) => { + const { hostName } = expandedHost; + + if (!hostName) { + return null; + } + + return isFlyoutView ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx new file mode 100644 index 00000000000000..71ab7f01ddd546 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx @@ -0,0 +1,204 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; + +import '../../../common/mock/match_media'; +import { + apolloClientObservable, + mockGlobalState, + TestProviders, + SUB_PLUGINS_REDUCER, + kibanaObservable, + createSecuritySolutionStorageMock, +} from '../../../common/mock'; +import { createStore, State } from '../../../common/store'; +import { DetailsPanel } from './index'; +import { TimelineExpandedDetail, TimelineTabs } from '../../../../common/types/timeline'; +import { FlowTarget } from '../../../../common/search_strategy/security_solution/network'; + +describe('Details Panel Component', () => { + const state: State = { ...mockGlobalState }; + + const { storage } = createSecuritySolutionStorageMock(); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + + const dataLessExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'hostDetail', + params: {}, + }, + }; + + const hostExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'hostDetail', + params: { + hostName: 'woohoo!', + }, + }, + }; + + const networkExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'networkDetail', + params: { + ip: 'woohoo!', + flowTarget: FlowTarget.source, + }, + }, + }; + + const eventExpandedDetail: TimelineExpandedDetail = { + [TimelineTabs.query]: { + panelView: 'eventDetail', + params: { + eventId: 'my-id', + indexName: 'my-index', + }, + }, + }; + + const mockProps = { + browserFields: {}, + docValueFields: [], + handleOnPanelClosed: jest.fn(), + isFlyoutView: false, + tabType: TimelineTabs.query, + timelineId: 'test', + }; + + describe('DetailsPanel: rendering', () => { + beforeEach(() => { + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should not render the DetailsPanel if no expanded detail has been set in the reducer', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + + test('it should not render the DetailsPanel if an expanded detail with a panelView, but not params have been set', () => { + state.timeline.timelineById.test.expandedDetail = dataLessExpandedDetail as TimelineExpandedDetail; // Casting as the dataless doesn't meet the actual type requirements + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:EventDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = eventExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Details Panel when the panelView is set and the associated params are set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('DetailsPanel')).toMatchSnapshot(); + }); + + test('it should render the Event Details view of the Details Panel in the flyout when the panelView is eventDetail and the eventId is set', () => { + const currentProps = { ...mockProps, isFlyoutView: true }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timeline:details-panel:flyout"]')).toMatchSnapshot(); + }); + + test('it should render the Event Details view in the Details Panel when the panelView is eventDetail and the eventId is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('EventDetails')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:HostDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = hostExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Host Details view in the Details Panel when the panelView is hostDetail and the hostName is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('HostDetails')).toMatchSnapshot(); + }); + }); + + describe('DetailsPanel:NetworkDetails: rendering', () => { + beforeEach(() => { + state.timeline.timelineById.test.expandedDetail = networkExpandedDetail; + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + }); + + test('it should render the Network Details view in the Details Panel when the panelView is networkDetail and the ip is set', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('NetworkDetails')).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx new file mode 100644 index 00000000000000..0482491562f57b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx @@ -0,0 +1,120 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { EuiFlyout } from '@elastic/eui'; +import styled from 'styled-components'; +import { timelineActions, timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; +import { BrowserFields, DocValueFields } from '../../../common/containers/source'; +import { TimelineTabs } from '../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { EventDetailsPanel } from './event_details'; +import { HostDetailsPanel } from './host_details'; +import { NetworkDetailsPanel } from './network_details'; + +const StyledEuiFlyout = styled(EuiFlyout)` + z-index: ${({ theme }) => theme.eui.euiZLevel7}; +`; + +interface DetailsPanelProps { + browserFields: BrowserFields; + docValueFields: DocValueFields[]; + handleOnPanelClosed?: () => void; + isFlyoutView?: boolean; + tabType?: TimelineTabs; + timelineId: string; +} + +/** + * This panel is used in both the main timeline as well as the flyouts on the host, detection, cases, and network pages. + * To prevent duplication the `isFlyoutView` prop is passed to determine the layout that should be used + * `tabType` defaults to query and `handleOnPanelClosed` defaults to unsetting the default query tab which is used for the flyout panel + */ +export const DetailsPanel = React.memo( + ({ + browserFields, + docValueFields, + handleOnPanelClosed, + isFlyoutView, + tabType, + timelineId, + }: DetailsPanelProps) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const expandedDetail = useDeepEqualSelector((state) => { + return (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail; + }); + + // To be used primarily in the flyout scenario where we don't want to maintain the tabType + const defaultOnPanelClose = useCallback(() => { + dispatch(timelineActions.toggleDetailPanel({ timelineId })); + }, [dispatch, timelineId]); + + const activeTab = tabType ?? TimelineTabs.query; + const closePanel = useCallback(() => { + if (handleOnPanelClosed) handleOnPanelClosed(); + else defaultOnPanelClose(); + }, [defaultOnPanelClose, handleOnPanelClosed]); + + if (!expandedDetail) return null; + + const currentTabDetail = expandedDetail[activeTab]; + + if (!currentTabDetail?.panelView) return null; + + let visiblePanel = null; // store in variable to make return statement more readable + const contextID = `${timelineId}-${activeTab}`; + + if (currentTabDetail?.panelView === 'eventDetail' && currentTabDetail?.params?.eventId) { + visiblePanel = ( + + ); + } + + if (currentTabDetail?.panelView === 'hostDetail' && currentTabDetail?.params?.hostName) { + visiblePanel = ( + + ); + } + + if (currentTabDetail?.panelView === 'networkDetail' && currentTabDetail?.params?.ip) { + visiblePanel = ( + + ); + } + + return isFlyoutView ? ( + + {visiblePanel} + + ) : ( + visiblePanel + ); + } +); + +DetailsPanel.displayName = 'DetailsPanel'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx new file mode 100644 index 00000000000000..b12b575681acf2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/expandable_network.tsx @@ -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 { EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { FlowTarget } from '../../../../../common/search_strategy'; +import { NetworkDetailsLink } from '../../../../common/components/links'; +import { IpOverview } from '../../../../network/components/details'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { networkToCriteria } from '../../../../common/components/ml/criteria/network_to_criteria'; +import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; +import { useKibana } from '../../../../common/lib/kibana'; +import { convertToBuildEsQuery } from '../../../../common/lib/keury'; +import { inputsSelectors } from '../../../../common/store'; +import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; +import { OverviewEmpty } from '../../../../overview/components/overview_empty'; +import { esQuery } from '../../../../../../../../src/plugins/data/public'; +import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { useNetworkDetails } from '../../../../network/containers/details'; +import { networkModel } from '../../../../network/store'; +import { useAnomaliesTableData } from '../../../../common/components/ml/anomaly/use_anomalies_table_data'; + +interface ExpandableNetworkProps { + expandedNetwork: { ip: string; flowTarget: FlowTarget }; +} + +export const ExpandableNetworkDetailsTitle = ({ ip }: { ip: string }) => ( + +

+ {i18n.translate('xpack.securitySolution.timeline.sidePanel.networkDetails.title', { + defaultMessage: 'Network details', + })} + {`: ${ip}`} +

+
+); + +export const ExpandableNetworkDetailsPageLink = ({ + expandedNetwork: { ip, flowTarget }, +}: ExpandableNetworkProps) => ( + + {i18n.translate( + 'xpack.securitySolution.timeline.sidePanel.networkDetails.networkDetailsPageLink', + { + defaultMessage: 'View details page', + } + )} + +); + +export const ExpandableNetworkDetails = ({ + contextID, + expandedNetwork, +}: ExpandableNetworkProps & { contextID: string }) => { + const { ip, flowTarget } = expandedNetwork; + const dispatch = useDispatch(); + const { to, from, isInitializing } = useGlobalTime(); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const type = networkModel.NetworkType.details; + const narrowDateRange = useCallback( + (score, interval) => { + const fromTo = scoreIntervalToDateTime(score, interval); + dispatch( + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }) + ); + }, + [dispatch] + ); + const { + services: { uiSettings }, + } = useKibana(); + + const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); + const filterQuery = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters, + }); + + const [loading, { id, networkDetails }] = useNetworkDetails({ + docValueFields, + skip: isInitializing, + filterQuery, + indexNames: selectedPatterns, + ip, + }); + + const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({ + criteriaFields: networkToCriteria(ip, flowTarget), + startDate: from, + endDate: to, + skip: isInitializing, + }); + + return indicesExist ? ( + + ) : ( + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx new file mode 100644 index 00000000000000..e05c9435fc456f --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/network_details/index.tsx @@ -0,0 +1,113 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import { + EuiFlexGroup, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { FlowTarget } from '../../../../../common/search_strategy'; +import { + ExpandableNetworkDetailsTitle, + ExpandableNetworkDetailsPageLink, + ExpandableNetworkDetails, +} from './expandable_network'; + +const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` + .euiFlyoutBody__overflow { + display: flex; + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + + .euiFlyoutBody__overflowContent { + flex: 1; + overflow-x: hidden; + overflow-y: scroll; + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 64px`}; + } + } +`; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + &.euiFlexItem { + flex: 1 0 0; + overflow-y: scroll; + overflow-x: hidden; + } +`; + +const StyledEuiFlexButtonWrapper = styled(EuiFlexItem)` + align-self: flex-start; +`; + +interface NetworkDetailsProps { + contextID: string; + expandedNetwork: { ip: string; flowTarget: FlowTarget }; + handleOnNetworkClosed: () => void; + isFlyoutView?: boolean; +} + +export const NetworkDetailsPanel = React.memo( + ({ contextID, expandedNetwork, handleOnNetworkClosed, isFlyoutView }: NetworkDetailsProps) => { + const { ip } = expandedNetwork; + + return isFlyoutView ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index 1ee5e39dfaa26c..16e2b28a120d76 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -30,10 +30,9 @@ describe('Actions', () => { ariaRowindex={2} checked={false} columnValues={'abc def'} - expanded={false} eventId="abc" loadingEventIds={[]} - onEventToggled={jest.fn()} + onEventDetailsPanelOpened={jest.fn()} onRowSelected={jest.fn()} showCheckboxes={true} /> @@ -52,9 +51,8 @@ describe('Actions', () => { checked={false} columnValues={'abc def'} eventId="abc" - expanded={false} loadingEventIds={[]} - onEventToggled={jest.fn()} + onEventDetailsPanelOpened={jest.fn()} onRowSelected={jest.fn()} showCheckboxes={false} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 2bbf793b9c78f5..9ce27aa936783d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -20,10 +20,9 @@ interface Props { columnValues: string; checked: boolean; onRowSelected: OnRowSelected; - expanded: boolean; eventId: string; loadingEventIds: Readonly; - onEventToggled: () => void; + onEventDetailsPanelOpened: () => void; showCheckboxes: boolean; } @@ -33,10 +32,9 @@ const ActionsComponent: React.FC = ({ additionalActions, checked, columnValues, - expanded, eventId, loadingEventIds, - onEventToggled, + onEventDetailsPanelOpened, onRowSelected, showCheckboxes, }) => { @@ -78,9 +76,8 @@ const ActionsComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index 9be338e6b44b31..abdfda3272d6ad 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -51,7 +51,7 @@ describe('EventColumnView', () => { loading: false, loadingEventIds: [], notesCount: 0, - onEventToggled: jest.fn(), + onEventDetailsPanelOpened: jest.fn(), onPinEvent: jest.fn(), onRowSelected: jest.fn(), onUnPinEvent: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index 0afb31984ee8e0..9d7b76af25a59d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -42,12 +42,11 @@ interface Props { data: TimelineNonEcsData[]; ecsData: Ecs; eventIdToNoteIds: Readonly>; - expanded: boolean; isEventPinned: boolean; isEventViewer?: boolean; loadingEventIds: Readonly; notesCount: number; - onEventToggled: () => void; + onEventDetailsPanelOpened: () => void; onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; onUnPinEvent: OnUnPinEvent; @@ -74,12 +73,11 @@ export const EventColumnView = React.memo( data, ecsData, eventIdToNoteIds, - expanded, isEventPinned = false, isEventViewer = false, loadingEventIds, notesCount, - onEventToggled, + onEventDetailsPanelOpened, onPinEvent, onRowSelected, onUnPinEvent, @@ -220,14 +218,12 @@ export const EventColumnView = React.memo( checked={Object.keys(selectedEventIds).includes(id)} columnValues={columnValues} onRowSelected={onRowSelected} - expanded={expanded} data-test-subj="actions" eventId={id} loadingEventIds={loadingEventIds} - onEventToggled={onEventToggled} + onEventDetailsPanelOpened={onEventDetailsPanelOpened} showCheckboxes={showCheckboxes} /> - = ({ }) => { const trGroupRef = useRef(null); const dispatch = useDispatch(); + // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created + const [activeStatefulEventContext] = useState({ timelineID: timelineId, tabType }); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const expandedEvent = useDeepEqualSelector( - (state) => - (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent[ - tabType ?? TimelineTabs.query - ] ?? {} + const expandedDetail = useDeepEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedDetail ?? {} ); + const hostName = useMemo(() => { + const hostNameArr = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.name' }); + return hostNameArr && hostNameArr.length > 0 ? hostNameArr[0] : null; + }, [event?.data]); + + const hostIPAddresses = useMemo(() => { + const ipList = getMappedNonEcsValue({ data: event?.data, fieldName: 'host.ip' }); + return ipList; + }, [event?.data]); + + const activeTab = tabType ?? TimelineTabs.query; + const activeExpandedDetail = expandedDetail[activeTab]; + + const isDetailPanelExpanded: boolean = + (activeExpandedDetail?.panelView === 'eventDetail' && + activeExpandedDetail?.params?.eventId === event._id) || + (activeExpandedDetail?.panelView === 'hostDetail' && + activeExpandedDetail?.params?.hostName === hostName) || + (activeExpandedDetail?.panelView === 'networkDetail' && + activeExpandedDetail?.params?.ip && + hostIPAddresses?.includes(activeExpandedDetail?.params?.ip)) || + false; + const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); const notesById = useDeepEqualSelector(getNotesByIds); const noteIds: string[] = eventIdToNoteIds[event._id] || emptyNotes; - const isExpanded = useMemo(() => expandedEvent && expandedEvent.eventId === event._id, [ - event._id, - expandedEvent, - ]); const notes: TimelineResultNote[] = useMemo( () => @@ -151,23 +175,28 @@ const StatefulEventComponent: React.FC = ({ [dispatch, timelineId] ); - const handleOnEventToggled = useCallback(() => { + const handleOnEventDetailPanelOpened = useCallback(() => { const eventId = event._id; const indexName = event._index!; + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'eventDetail', + params: { + eventId, + indexName, + }, + }; + dispatch( - timelineActions.toggleExpandedEvent({ + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, tabType, timelineId, - event: { - eventId, - indexName, - }, }) ); if (timelineId === TimelineId.active && tabType === TimelineTabs.query) { - activeTimeline.toggleExpandedEvent({ eventId, indexName }); + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); } }, [dispatch, event._id, event._index, tabType, timelineId]); @@ -207,63 +236,64 @@ const StatefulEventComponent: React.FC = ({ ); return ( - - + + + - - - - + + + + - {RowRendererContent} - - + {RowRendererContent} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx new file mode 100644 index 00000000000000..34abc06371aacf --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event_context.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { TimelineTabs } from '../../../../../../common/types/timeline'; + +interface StatefulEventContext { + tabType: TimelineTabs | undefined; + timelineID: string; +} + +// This context is available to all children of the stateful_event component where the provider is currently set +export const StatefulEventContext = React.createContext(null); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 7decff82707367..723e4c3de5c275 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -240,14 +240,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'query', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); @@ -263,14 +264,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'pinned', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); @@ -286,14 +288,15 @@ describe('Body', () => { expect(mockDispatch).toBeCalledTimes(1); expect(mockDispatch.mock.calls[0][0]).toEqual({ payload: { - event: { + panelView: 'eventDetail', + params: { eventId: '1', indexName: undefined, }, tabType: 'notes', timelineId: 'timeline-test', }, - type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + type: 'x-pack/security_solution/local/timeline/TOGGLE_DETAIL_PANEL', }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 8aa1425bbe52d7..4df6eb16ccb623 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -60,6 +60,10 @@ const EXTRA_WIDTH = 4; // px export type StatefulBodyProps = OwnProps & PropsFromRedux; +/** + * The Body component is used everywhere timeline is used within the security application. It is the highest level component + * that is shared across all implementations of the timeline. + */ export const BodyComponent = React.memo( ({ activePage, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx index e97738d95e43fa..9d716f8325cbca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx @@ -243,7 +243,7 @@ describe('Events', () => { expect(wrapper.find('[data-test-subj="truncatable-message"]').exists()).toEqual(false); }); - test('it renders a hyperlink to the hosts details page when fieldName is host.name, and a hostname is provided', () => { + test('it renders a button to open the hosts details panel when fieldName is host.name, and a hostname is provided', () => { const wrapper = mount( { /> ); - expect(wrapper.find('[data-test-subj="host-details-link"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="host-details-button"]').exists()).toEqual(true); }); - test('it does NOT render a hyperlink to the hosts details page when fieldName is host.name, but a hostname is NOT provided', () => { + test('it does NOT render a button to open the hosts details panel when fieldName is host.name, but a hostname is NOT provided', () => { const wrapper = mount( { /> ); - expect(wrapper.find('[data-test-subj="host-details-link"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="host-details-button"]').exists()).toEqual(false); }); test('it renders placeholder text when fieldName is host.name, but a hostname is NOT provided', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx index 50ed97d5fd8b65..c57cfce3cebe67 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx @@ -5,13 +5,21 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useContext } from 'react'; +import { useDispatch } from 'react-redux'; import { isString } from 'lodash/fp'; - +import { LinkAnchor } from '../../../../../common/components/links'; +import { + TimelineId, + TimelineTabs, + TimelineExpandedDetailType, +} from '../../../../../../common/types/timeline'; import { DefaultDraggable } from '../../../../../common/components/draggables'; import { getEmptyTagValue } from '../../../../../common/components/empty_value'; -import { HostDetailsLink } from '../../../../../common/components/links'; import { TruncatableText } from '../../../../../common/components/truncatable_text'; +import { StatefulEventContext } from '../events/stateful_event_context'; +import { activeTimeline } from '../../../../containers/active_timeline_context'; +import { timelineActions } from '../../../../store/timeline'; interface Props { contextId: string; @@ -21,18 +29,48 @@ interface Props { } const HostNameComponent: React.FC = ({ fieldName, contextId, eventId, value }) => { - const hostname = `${value}`; + const dispatch = useDispatch(); + const eventContext = useContext(StatefulEventContext); + const hostName = `${value}`; + + const openHostDetailsSidePanel = useCallback( + (e) => { + e.preventDefault(); + if (hostName && eventContext?.tabType && eventContext?.timelineID) { + const { timelineID, tabType } = eventContext; + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'hostDetail', + params: { + hostName, + }, + }; + + dispatch( + timelineActions.toggleDetailPanel({ + ...updatedExpandedDetail, + timelineId: timelineID, + tabType, + }) + ); + + if (timelineID === TimelineId.active && tabType === TimelineTabs.query) { + activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); + } + } + }, + [dispatch, eventContext, hostName] + ); - return isString(value) && hostname.length > 0 ? ( + return isString(value) && hostName.length > 0 ? ( - - {value} - + + {hostName} + ) : ( getEmptyTagValue() diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx deleted file mode 100644 index 6b8381c54de018..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx +++ /dev/null @@ -1,85 +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 { some } from 'lodash/fp'; -import { EuiSpacer } from '@elastic/eui'; -import React, { useMemo } from 'react'; -import deepEqual from 'fast-deep-equal'; - -import { BrowserFields, DocValueFields } from '../../../common/containers/source'; -import { - ExpandableEvent, - ExpandableEventTitle, - HandleOnEventClosed, -} from '../../../timelines/components/timeline/expandable_event'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; -import { useTimelineEventsDetails } from '../../containers/details'; -import { timelineSelectors } from '../../store/timeline'; -import { timelineDefaults } from '../../store/timeline/defaults'; -import { TimelineTabs } from '../../../../common/types/timeline'; - -interface EventDetailsProps { - browserFields: BrowserFields; - docValueFields: DocValueFields[]; - tabType: TimelineTabs; - timelineId: string; - handleOnEventClosed?: HandleOnEventClosed; -} - -const EventDetailsComponent: React.FC = ({ - browserFields, - docValueFields, - tabType, - timelineId, - handleOnEventClosed, -}) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const expandedEvent = useDeepEqualSelector( - (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent[tabType] ?? {} - ); - - const [loading, detailsData] = useTimelineEventsDetails({ - docValueFields, - indexName: expandedEvent.indexName!, - eventId: expandedEvent.eventId!, - skip: !expandedEvent.eventId, - }); - - const isAlert = useMemo( - () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), - [detailsData] - ); - - return ( - <> - - - - - ); -}; - -export const EventDetails = React.memo( - EventDetailsComponent, - (prevProps, nextProps) => - deepEqual(prevProps.browserFields, nextProps.browserFields) && - deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId && - prevProps.handleOnEventClosed === nextProps.handleOnEventClosed -); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index c37fc93e33b08e..09b32b8f6140d7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -18,7 +18,7 @@ import { isTab } from '../../../common/components/accessibility/helpers'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { FlyoutHeader, FlyoutHeaderPanel } from '../flyout/header'; -import { TimelineType, TimelineTabs, TimelineId } from '../../../../common/types/timeline'; +import { TimelineType, TimelineId } from '../../../../common/types/timeline'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { activeTimeline } from '../../containers/active_timeline_context'; import { EVENTS_COUNT_BUTTON_CLASS_NAME, onTimelineTabKeyPressed } from './helpers'; @@ -69,9 +69,7 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { id: timelineId, columns: defaultHeaders, indexNames: selectedPatterns, - expandedEvent: { - [TimelineTabs.query]: activeTimeline.getExpandedEvent(), - }, + expandedDetail: activeTimeline.getExpandedDetail(), show: false, }) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx index b083b346668448..0d32e790dab508 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -31,8 +31,8 @@ import { CREATED_BY, NOTES } from '../../notes/translations'; import { PARTICIPANTS } from '../../../../cases/translations'; import { NotePreviews } from '../../open_timeline/note_previews'; import { TimelineResultNote } from '../../open_timeline/types'; -import { EventDetails } from '../event_details'; import { getTimelineNoteSelector } from './selectors'; +import { DetailsPanel } from '../../side_panel'; const FullWidthFlexGroup = styled(EuiFlexGroup)` width: 100%; @@ -125,7 +125,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId } const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); const { createdBy, - expandedEvent, + expandedDetail, eventIdToNoteIds, noteIds, status: timelineStatus, @@ -162,22 +162,22 @@ const NotesTabContentComponent: React.FC = ({ timelineId } [dispatch, timelineId] ); - const handleOnEventClosed = useCallback(() => { - dispatch(timelineActions.toggleExpandedEvent({ tabType: TimelineTabs.notes, timelineId })); + const handleOnPanelClosed = useCallback(() => { + dispatch(timelineActions.toggleDetailPanel({ tabType: TimelineTabs.notes, timelineId })); }, [dispatch, timelineId]); - const EventDetailsContent = useMemo( + const DetailsPanelContent = useMemo( () => - expandedEvent?.eventId != null ? ( - ) : null, - [browserFields, docValueFields, expandedEvent, handleOnEventClosed, timelineId] + [browserFields, docValueFields, expandedDetail, handleOnPanelClosed, timelineId] ); const SidebarContent = useMemo( @@ -216,7 +216,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId } - {EventDetailsContent ?? SidebarContent} + {DetailsPanelContent ?? SidebarContent} ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts index 84e39e5481afde..bc0317f4c42822 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/selectors.ts @@ -13,7 +13,7 @@ export const getTimelineNoteSelector = () => createSelector(timelineSelectors.selectTimeline, (timeline) => { return { createdBy: timeline.createdBy, - expandedEvent: timeline.expandedEvent?.notes ?? {}, + expandedDetail: timeline.expandedDetail ?? {}, eventIdToNoteIds: timeline?.eventIdToNoteIds ?? {}, noteIds: timeline.noteIds, status: timeline.status, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap index f5064ba66cf2fa..e55c1cc8f0af38 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap @@ -135,7 +135,7 @@ In other use cases the message field can be used to concatenate different values } onEventClosed={[MockFunction]} pinnedEventIds={Object {}} - showEventDetails={false} + showExpandedDetails={false} sort={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx index 56d53c5fecb967..2107969df22b8e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx @@ -96,7 +96,7 @@ describe('PinnedTabContent', () => { itemsPerPageOptions: [5, 10, 20], sort, pinnedEventIds: {}, - showEventDetails: false, + showExpandedDetails: false, onEventClosed: jest.fn(), }; }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx index 98cc130a38de33..68461a7234d091 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.tsx @@ -25,11 +25,11 @@ import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineDefaults } from '../../../store/timeline/defaults'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { TimelineModel } from '../../../store/timeline/model'; -import { EventDetails } from '../event_details'; -import { ToggleExpandedEvent } from '../../../store/timeline/actions'; +import { ToggleDetailPanel } from '../../../store/timeline/actions'; import { State } from '../../../../common/store'; import { calculateTotalPages } from '../helpers'; import { TimelineTabs } from '../../../../../common/types/timeline'; +import { DetailsPanel } from '../../side_panel'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; @@ -90,7 +90,7 @@ export const PinnedTabContentComponent: React.FC = ({ itemsPerPageOptions, pinnedEventIds, onEventClosed, - showEventDetails, + showExpandedDetails, sort, }) => { const { browserFields, docValueFields, loading: loadingSourcerer } = useSourcererScope( @@ -169,7 +169,7 @@ export const PinnedTabContentComponent: React.FC = ({ timerangeKind: undefined, }); - const handleOnEventClosed = useCallback(() => { + const handleOnPanelClosed = useCallback(() => { onEventClosed({ tabType: TimelineTabs.pinned, timelineId }); }, [timelineId, onEventClosed]); @@ -217,16 +217,16 @@ export const PinnedTabContentComponent: React.FC = ({ - {showEventDetails && ( + {showExpandedDetails && ( <> - @@ -242,7 +242,7 @@ const makeMapStateToProps = () => { const timeline: TimelineModel = getTimeline(state, timelineId) ?? timelineDefaults; const { columns, - expandedEvent, + expandedDetail, itemsPerPage, itemsPerPageOptions, pinnedEventIds, @@ -255,7 +255,8 @@ const makeMapStateToProps = () => { itemsPerPage, itemsPerPageOptions, pinnedEventIds, - showEventDetails: !!expandedEvent[TimelineTabs.pinned]?.eventId, + showExpandedDetails: + !!expandedDetail[TimelineTabs.pinned] && !!expandedDetail[TimelineTabs.pinned]?.panelView, sort, }; }; @@ -263,8 +264,8 @@ const makeMapStateToProps = () => { }; const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ - onEventClosed: (args: ToggleExpandedEvent) => { - dispatch(timelineActions.toggleExpandedEvent(args)); + onEventClosed: (args: ToggleDetailPanel) => { + dispatch(timelineActions.toggleDetailPanel(args)); }, }); @@ -278,7 +279,7 @@ const PinnedTabContent = connector( (prevProps, nextProps) => prevProps.itemsPerPage === nextProps.itemsPerPage && prevProps.onEventClosed === nextProps.onEventClosed && - prevProps.showEventDetails === nextProps.showEventDetails && + prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.timelineId === nextProps.timelineId && deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index 4fbf7788d91223..0688a10b31eefb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -262,7 +262,7 @@ In other use cases the message field can be used to concatenate different values } end="2018-03-24T03:33:52.253Z" eventType="all" - expandedEvent={Object {}} + expandedDetail={Object {}} filters={Array []} isLive={false} itemsPerPage={5} @@ -278,7 +278,7 @@ In other use cases the message field can be used to concatenate different values onEventClosed={[MockFunction]} show={true} showCallOutUnauthorizedMsg={false} - showEventDetails={false} + showExpandedDetails={false} sort={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 882c0c90973b37..c7d27da64c6506 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -96,9 +96,8 @@ describe('Timeline', () => { columns: defaultHeaders, dataProviders: mockDataProviders, end: endDate, - expandedEvent: {}, eventType: 'all', - showEventDetails: false, + expandedDetail: {}, filters: [], timelineId: TimelineId.test, isLive: false, @@ -108,6 +107,7 @@ describe('Timeline', () => { kqlQueryExpression: '', onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, + showExpandedDetails: false, sort, start: startDate, status: TimelineStatus.active, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 25acd489169443..c61be4951db76f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -46,12 +46,12 @@ import { timelineDefaults } from '../../../../timelines/store/timeline/defaults' import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; -import { EventDetails } from '../event_details'; import { TimelineDatePickerLock } from '../date_picker_lock'; import { HideShowContainer } from '../styles'; import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { activeTimeline } from '../../../containers/active_timeline_context'; -import { ToggleExpandedEvent } from '../../../store/timeline/actions'; +import { ToggleDetailPanel } from '../../../store/timeline/actions'; +import { DetailsPanel } from '../../side_panel'; const TimelineHeaderContainer = styled.div` margin-top: 6px; @@ -139,7 +139,7 @@ export const QueryTabContentComponent: React.FC = ({ dataProviders, end, eventType, - expandedEvent, + expandedDetail, filters, timelineId, isLive, @@ -150,7 +150,7 @@ export const QueryTabContentComponent: React.FC = ({ onEventClosed, show, showCallOutUnauthorizedMsg, - showEventDetails, + showExpandedDetails, start, status, sort, @@ -245,16 +245,17 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, }); - const handleOnEventClosed = useCallback(() => { + const handleOnPanelClosed = useCallback(() => { onEventClosed({ tabType: TimelineTabs.query, timelineId }); - if (timelineId === TimelineId.active) { - activeTimeline.toggleExpandedEvent({ - eventId: expandedEvent.eventId!, - indexName: expandedEvent.indexName!, - }); + if ( + expandedDetail[TimelineTabs.query]?.panelView && + timelineId === TimelineId.active && + showExpandedDetails + ) { + activeTimeline.toggleExpandedDetail({}); } - }, [timelineId, onEventClosed, expandedEvent.eventId, expandedEvent.indexName]); + }, [onEventClosed, timelineId, expandedDetail, showExpandedDetails]); useEffect(() => { setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer }); @@ -350,16 +351,16 @@ export const QueryTabContentComponent: React.FC = ({ - {showEventDetails && ( + {showExpandedDetails && ( <> - @@ -382,7 +383,7 @@ const makeMapStateToProps = () => { columns, dataProviders, eventType, - expandedEvent, + expandedDetail, filters, itemsPerPage, itemsPerPageOptions, @@ -406,7 +407,7 @@ const makeMapStateToProps = () => { dataProviders, eventType: eventType ?? 'raw', end: input.timerange.to, - expandedEvent: expandedEvent[TimelineTabs.query] ?? {}, + expandedDetail, filters: timelineFilter, timelineId, isLive: input.policy.kind === 'interval', @@ -415,8 +416,9 @@ const makeMapStateToProps = () => { kqlMode, kqlQueryExpression, showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), - showEventDetails: !!expandedEvent[TimelineTabs.query]?.eventId, show, + showExpandedDetails: + !!expandedDetail[TimelineTabs.query] && !!expandedDetail[TimelineTabs.query]?.panelView, sort, start: input.timerange.from, status, @@ -437,8 +439,8 @@ const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ }) ); }, - onEventClosed: (args: ToggleExpandedEvent) => { - dispatch(timelineActions.toggleExpandedEvent(args)); + onEventClosed: (args: ToggleDetailPanel) => { + dispatch(timelineActions.toggleDetailPanel(args)); }, }); @@ -460,7 +462,7 @@ const QueryTabContent = connector( prevProps.onEventClosed === nextProps.onEventClosed && prevProps.show === nextProps.show && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && - prevProps.showEventDetails === nextProps.showEventDetails && + prevProps.showExpandedDetails === nextProps.showExpandedDetails && prevProps.status === nextProps.status && prevProps.timelineId === nextProps.timelineId && prevProps.updateEventTypeAndIndexesName === nextProps.updateEventTypeAndIndexesName && diff --git a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts index 190cf53689ec08..93e53fa544bbc0 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { TimelineExpandedEventType } from '../../../common/types/timeline'; +import { + TimelineExpandedDetail, + TimelineExpandedDetailType, + TimelineTabs, +} from '../../../common/types/timeline'; import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline'; import { TimelineArgs } from '.'; @@ -22,7 +26,7 @@ import { TimelineArgs } from '.'; class ActiveTimelineEvents { private _activePage: number = 0; - private _expandedEvent: TimelineExpandedEventType = {}; + private _expandedDetail: TimelineExpandedDetail = {}; private _pageName: string = ''; private _request: TimelineEventsAllRequestOptions | null = null; private _response: TimelineArgs | null = null; @@ -35,20 +39,40 @@ class ActiveTimelineEvents { this._activePage = activePage; } - getExpandedEvent() { - return this._expandedEvent; + getExpandedDetail() { + return this._expandedDetail; } - toggleExpandedEvent(expandedEvent: TimelineExpandedEventType) { - if (expandedEvent.eventId === this._expandedEvent.eventId) { - this._expandedEvent = {}; + toggleExpandedDetail(expandedDetail: TimelineExpandedDetailType) { + const queryTab = TimelineTabs.query; + const currentExpandedDetail = this._expandedDetail[queryTab]; + let isSameExpandedDetail; + + // Check if the stored details matches the incoming detail + if (currentExpandedDetail?.panelView === 'eventDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'eventDetail' && + expandedDetail?.params?.eventId === currentExpandedDetail?.params?.eventId; + } else if (currentExpandedDetail?.panelView === 'hostDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'hostDetail' && + expandedDetail?.params?.hostName === currentExpandedDetail?.params?.hostName; + } else if (currentExpandedDetail?.panelView === 'networkDetail') { + isSameExpandedDetail = + expandedDetail?.panelView === 'networkDetail' && + expandedDetail?.params?.ip === currentExpandedDetail?.params?.ip; + } + + // if so, unset it, otherwise set it + if (isSameExpandedDetail) { + this._expandedDetail = {}; } else { - this._expandedEvent = expandedEvent; + this._expandedDetail = { [queryTab]: { ...expandedDetail } }; } } - setExpandedEvent(expandedEvent: TimelineExpandedEventType) { - this._expandedEvent = expandedEvent; + setExpandedDetail(expandedDetail: TimelineExpandedDetail) { + this._expandedDetail = expandedDetail; } getPageName() { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 57815a6d6bcd71..0d53d01fa71315 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -113,7 +113,7 @@ export const useTimelineEvents = ({ clearSignalsState(); if (id === TimelineId.active) { - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); activeTimeline.setActivePage(newActivePage); } @@ -178,7 +178,7 @@ export const useTimelineEvents = ({ updatedAt: Date.now(), }; if (id === TimelineId.active) { - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); activeTimeline.setPageName(pageName); activeTimeline.setRequest(request); activeTimeline.setResponse(newTimelineResponse); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index a38d81a68d1bf3..c9e3c8305a30de 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -20,10 +20,10 @@ import { KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, - TimelineExpandedEventType, + TimelineExpandedDetail, + TimelineExpandedDetailType, TimelineTypeLiteral, RowRendererId, - TimelineExpandedEvent, TimelineTabs, } from '../../../../common/types/timeline'; import { InsertTimeline } from './types'; @@ -38,12 +38,12 @@ export const addNoteToEvent = actionCreator<{ id: string; noteId: string; eventI 'ADD_NOTE_TO_EVENT' ); -export interface ToggleExpandedEvent { - event?: TimelineExpandedEventType; +export type ToggleDetailPanel = TimelineExpandedDetailType & { tabType?: TimelineTabs; timelineId: string; -} -export const toggleExpandedEvent = actionCreator('TOGGLE_EXPANDED_EVENT'); +}; + +export const toggleDetailPanel = actionCreator('TOGGLE_DETAIL_PANEL'); export const upsertColumn = actionCreator<{ column: ColumnHeaderOptions; @@ -67,7 +67,7 @@ export interface TimelineInput { end: string; }; excludedRowRendererIds?: RowRendererId[]; - expandedEvent?: TimelineExpandedEvent; + expandedDetail?: TimelineExpandedDetail; filters?: Filter[]; columns: ColumnHeaderOptions[]; itemsPerPage?: number; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index aaaf369f7bd5c8..44a5c05e398f16 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -25,7 +25,7 @@ export const timelineDefaults: SubsetTimelineModel & Pick { description: '', eventIdToNoteIds: {}, eventType: 'all', - expandedEvent: {}, + expandedDetail: {}, excludedRowRendererIds: [], highlightedDropAndProviderId: '', historyIds: [], diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 584d270d8bea4f..3d92397f4ab507 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -82,7 +82,7 @@ describe('epicLocalStorage', () => { dataProviders: mockDataProviders, end: endDate, eventType: 'all', - expandedEvent: {}, + expandedDetail: {}, filters: [], isLive: false, itemsPerPage: 5, @@ -91,7 +91,7 @@ describe('epicLocalStorage', () => { kqlQueryExpression: '', onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, - showEventDetails: false, + showExpandedDetails: false, start: startDate, status: TimelineStatus.active, sort, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index d5d60857abb9a5..864e52fc377a0c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -8,6 +8,7 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import uuid from 'uuid'; +import { ToggleDetailPanel } from './actions'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; @@ -24,12 +25,13 @@ import { SerializedFilterQuery } from '../../../common/store/model'; import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { TimelineEventsType, - TimelineExpandedEvent, + TimelineExpandedDetail, TimelineTypeLiteral, TimelineType, RowRendererId, TimelineStatus, TimelineId, + TimelineTabs, } from '../../../../common/types/timeline'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; @@ -144,7 +146,7 @@ export const addTimelineToStore = ({ }: AddTimelineParams): TimelineById => { if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) { activeTimeline.setActivePage(0); - activeTimeline.setExpandedEvent({}); + activeTimeline.setExpandedDetail({}); } return { ...timelineById, @@ -171,7 +173,7 @@ interface AddNewTimelineParams { end: string; }; excludedRowRendererIds?: RowRendererId[]; - expandedEvent?: TimelineExpandedEvent; + expandedDetail?: TimelineExpandedDetail; filters?: Filter[]; id: string; itemsPerPage?: number; @@ -192,7 +194,7 @@ export const addNewTimeline = ({ dataProviders = [], dateRange: maybeDateRange, excludedRowRendererIds = [], - expandedEvent = {}, + expandedDetail = {}, filters = timelineDefaults.filters, id, itemsPerPage = timelineDefaults.itemsPerPage, @@ -221,7 +223,7 @@ export const addNewTimeline = ({ columns, dataProviders, dateRange, - expandedEvent, + expandedDetail, excludedRowRendererIds, filters, itemsPerPage, @@ -1431,3 +1433,21 @@ export const updateExcludedRowRenderersIds = ({ }, }; }; + +export const updateTimelineDetailsPanel = (action: ToggleDetailPanel) => { + const { tabType } = action; + + const panelViewOptions = new Set(['eventDetail', 'hostDetail', 'networkDetail']); + const expandedTabType = tabType ?? TimelineTabs.query; + + return action.panelView && panelViewOptions.has(action.panelView) + ? { + [expandedTabType]: { + params: action.params ? { ...action.params } : {}, + panelView: action.panelView, + }, + } + : { + [expandedTabType]: {}, + }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index cc9b47383e9c95..e5036efd41df4d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -14,7 +14,7 @@ import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline' import { SerializedFilterQuery } from '../../../common/store/types'; import type { TimelineEventsType, - TimelineExpandedEvent, + TimelineExpandedDetail, TimelineType, TimelineStatus, RowRendererId, @@ -63,7 +63,8 @@ export interface TimelineModel { eventIdToNoteIds: Record; /** A list of Ids of excluded Row Renderers */ excludedRowRendererIds: RowRendererId[]; - expandedEvent: TimelineExpandedEvent; + /** This holds the view information for the flyout when viewing timeline in a consuming view (i.e. hosts page) or the side panel in the primary timeline view */ + expandedDetail: TimelineExpandedDetail; filters?: Filter[]; /** When non-empty, display a graph view for this event */ graphEventId?: string; @@ -143,7 +144,7 @@ export type SubsetTimelineModel = Readonly< | 'eventType' | 'eventIdToNoteIds' | 'excludedRowRendererIds' - | 'expandedEvent' + | 'expandedDetail' | 'graphEventId' | 'highlightedDropAndProviderId' | 'historyIds' diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 346a82ed0da1de..c4988673f49b61 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -79,7 +79,7 @@ const basicTimeline: TimelineModel = { description: '', eventIdToNoteIds: {}, excludedRowRendererIds: [], - expandedEvent: {}, + expandedDetail: {}, highlightedDropAndProviderId: '', historyIds: [], id: 'foo', diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 791100a8b9e2a9..7271eafa14863d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -35,7 +35,7 @@ import { showCallOutUnauthorizedMsg, showTimeline, startTimelineSaving, - toggleExpandedEvent, + toggleDetailPanel, unPinEvent, updateAutoSaveMsg, updateColumns, @@ -99,11 +99,12 @@ import { updateSavedQuery, updateGraphEventId, updateFilters, + updateTimelineDetailsPanel, updateTimelineEventType, } from './helpers'; import { TimelineState, EMPTY_TIMELINE_BY_ID } from './types'; -import { TimelineType, TimelineTabs } from '../../../../common/types/timeline'; +import { TimelineType } from '../../../../common/types/timeline'; export const initialTimelineState: TimelineState = { timelineById: EMPTY_TIMELINE_BY_ID, @@ -130,6 +131,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) dataProviders, dateRange, excludedRowRendererIds, + expandedDetail = {}, show, columns, itemsPerPage, @@ -148,6 +150,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) dataProviders, dateRange, excludedRowRendererIds, + expandedDetail, filters, id, itemsPerPage, @@ -178,22 +181,19 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: addTimelineNoteToEvent({ id, noteId, eventId, timelineById: state.timelineById }), })) - .case(toggleExpandedEvent, (state, { tabType, timelineId, event = {} }) => { - const expandedTabType = tabType ?? TimelineTabs.query; - return { - ...state, - timelineById: { - ...state.timelineById, - [timelineId]: { - ...state.timelineById[timelineId], - expandedEvent: { - ...state.timelineById[timelineId].expandedEvent, - [expandedTabType]: event, - }, + .case(toggleDetailPanel, (state, action) => ({ + ...state, + timelineById: { + ...state.timelineById, + [action.timelineId]: { + ...state.timelineById[action.timelineId], + expandedDetail: { + ...state.timelineById[action.timelineId].expandedDetail, + ...updateTimelineDetailsPanel(action), }, }, - }; - }) + }, + })) .case(addProvider, (state, { id, provider }) => ({ ...state, timelineById: addTimelineProvider({ id, provider, timelineById: state.timelineById }), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/ecs_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/ecs_mapping.json index 6126ee462ec202..70b62d569b9d37 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/ecs_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/ecs_mapping.json @@ -2457,6 +2457,144 @@ "ignore_above": 1024, "type": "keyword" }, + "indicator": { + "type": "nested", + "properties": { + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "confidence": { + "ignore_above": 1024, + "type": "keyword" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "type": "wildcard" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "first_seen": { + "type": "date" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "last_seen": { + "type": "date" + }, + "marking": { + "properties": { + "tlp": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "matched": { + "properties": { + "atomic": { + "ignore_above": 1024, + "type": "keyword" + }, + "field": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner_stats": { + "type": "long" + }, + "sightings": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, "tactic": { "properties": { "id": { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts index c9a4f168224d43..48036ec73511b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts @@ -8,7 +8,20 @@ import signalsMapping from './signals_mapping.json'; import ecsMapping from './ecs_mapping.json'; -export const SIGNALS_TEMPLATE_VERSION = 14; +/** + @constant + @type {number} + @description This value represents the template version assumed by app code. + If this number is greater than the user's signals index version, the + detections UI will attempt to update the signals template and roll over to + a new signals index. + + If making mappings changes in a patch release, this number should be incremented by 1. + If making mappings changes in a minor release, this number should be + incremented by 10 in order to add "room" for the aforementioned patch + release +*/ +export const SIGNALS_TEMPLATE_VERSION = 24; export const MIN_EQL_RULE_INDEX_VERSION = 2; export const getSignalsTemplate = (index: string) => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index b506a2463a3113..55d128225c5553 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -14,6 +14,7 @@ import { repeatedSearchResultsWithSortId, repeatedSearchResultsWithNoSortId, sampleDocSearchResultsNoSortIdNoHits, + sampleDocWithSortId, } from './__mocks__/es_results'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { buildRuleMessageFactory } from './rule_messages'; @@ -870,4 +871,93 @@ describe('searchAfterAndBulkCreate', () => { expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); + + it('invokes the enrichment callback with signal search results', async () => { + const sampleParams = sampleRuleAlertParams(30); + mockService.callCluster + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + + const mockEnrichment = jest.fn((a) => a); + const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ + enrichment: mockEnrichment, + ruleParams: sampleParams, + gap: moment.duration(2, 'm'), + previousStartedAt: moment().subtract(10, 'm').toDate(), + listClient, + exceptionsList: [], + services: mockService, + logger: mockLogger, + eventsTelemetry: undefined, + id: sampleRuleGuid, + inputIndexPattern, + signalsIndex: DEFAULT_SIGNALS_INDEX, + name: 'rule-name', + actions: [], + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: '5m', + enabled: true, + pageSize: 1, + filter: undefined, + refresh: false, + tags: ['some fake tag 1', 'some fake tag 2'], + throttle: 'no_actions', + buildRuleMessage, + }); + + expect(mockEnrichment).toHaveBeenCalledWith( + expect.objectContaining({ + hits: expect.objectContaining({ + hits: expect.arrayContaining([ + expect.objectContaining({ + ...sampleDocWithSortId(), + _id: expect.any(String), + }), + ]), + }), + }) + ); + expect(success).toEqual(true); + expect(mockService.callCluster).toHaveBeenCalledTimes(7); + expect(createdSignalsCount).toEqual(3); + expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index b821909ca907c7..061aa4bba5a412 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -7,6 +7,7 @@ /* eslint-disable complexity */ +import { identity } from 'lodash'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; import { filterEventsAgainstList } from './filters/filter_events_against_list'; @@ -49,6 +50,7 @@ export const searchAfterAndBulkCreate = async ({ tags, throttle, buildRuleMessage, + enrichment = identity, }: SearchAfterAndBulkCreateParams): Promise => { let toReturn = createSearchAfterReturnType(); @@ -106,7 +108,7 @@ export const searchAfterAndBulkCreate = async ({ services, logger, filter, - pageSize: tuple.maxSignals < pageSize ? Math.ceil(tuple.maxSignals) : pageSize, // maximum number of docs to receive per search result. + pageSize: Math.ceil(Math.min(tuple.maxSignals, pageSize)), timestampOverride: ruleParams.timestampOverride, excludeDocsWithTimestampOverride: true, }); @@ -117,14 +119,12 @@ export const searchAfterAndBulkCreate = async ({ backupSortId = lastSortId[0]; hasBackupSortId = true; } else { - // if no sort id on backup search and the initial search result was also empty logger.debug(buildRuleMessage('backupSortIds was empty on searchResultB')); hasBackupSortId = false; } mergedSearchResults = mergeSearchResults([mergedSearchResults, searchResultB]); - // merge the search result from the secondary search with the first toReturn = mergeReturns([ toReturn, createSearchAfterReturnTypeFromResponse({ @@ -139,7 +139,6 @@ export const searchAfterAndBulkCreate = async ({ } if (hasSortId) { - // only execute search if we have something to sort on or if it is the first search const { searchResult, searchDuration, searchErrors } = await singleSearchAfter({ buildRuleMessage, searchAfterSortId: sortId, @@ -149,7 +148,7 @@ export const searchAfterAndBulkCreate = async ({ services, logger, filter, - pageSize: tuple.maxSignals < pageSize ? Math.ceil(tuple.maxSignals) : pageSize, // maximum number of docs to receive per search result. + pageSize: Math.ceil(Math.min(tuple.maxSignals, pageSize)), timestampOverride: ruleParams.timestampOverride, excludeDocsWithTimestampOverride: false, }); @@ -166,10 +165,6 @@ export const searchAfterAndBulkCreate = async ({ }), ]); - // we are guaranteed to have searchResult hits at this point - // because we check before if the totalHits or - // searchResult.hits.hits.length is 0 - // call this function setSortIdOrExit() const lastSortId = searchResult.hits.hits[searchResult.hits.hits.length - 1]?.sort; if (lastSortId != null && lastSortId.length !== 0) { sortId = lastSortId[0]; @@ -186,14 +181,6 @@ export const searchAfterAndBulkCreate = async ({ buildRuleMessage(`searchResult.hit.hits.length: ${mergedSearchResults.hits.hits.length}`) ); - // search results yielded zero hits so exit - // with search_after, these two values can be different when - // searching with the last sortId of a consecutive search_after - // yields zero hits, but there were hits using the previous - // sortIds. - // e.g. totalHits was 156, index 50 of 100 results, do another search-after - // this time with a new sortId, index 22 of the remaining 56, get another sortId - // search with that sortId, total is still 156 but the hits.hits array is empty. if (totalHits === 0 || mergedSearchResults.hits.hits.length === 0) { logger.debug( buildRuleMessage( @@ -228,6 +215,8 @@ export const searchAfterAndBulkCreate = async ({ tuple.maxSignals - signalsCreatedCount ); } + const enrichedEvents = await enrichment(filteredEvents); + const { bulkCreateDuration: bulkDuration, createdItemsCount: createdCount, @@ -236,7 +225,7 @@ export const searchAfterAndBulkCreate = async ({ errors: bulkErrors, } = await singleBulkCreate({ buildRuleMessage, - filteredEvents, + filteredEvents: enrichedEvents, ruleParams, services, logger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index c7278d60ca97ea..02a0582e540f45 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -94,6 +94,7 @@ describe('rules_notification_alert_type', () => { mlSystemProvider: jest.fn(), modulesProvider: jest.fn(), resultsServiceProvider: jest.fn(), + alertingServiceProvider: jest.fn(), }; let payload: jest.Mocked; let alert: ReturnType; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts new file mode 100644 index 00000000000000..b14d1482189388 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts @@ -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. + */ + +import { SignalSearchResponse, SignalsEnrichment } from '../types'; +import { enrichSignalThreatMatches } from './enrich_signal_threat_matches'; +import { getThreatList } from './get_threat_list'; +import { BuildThreatEnrichmentOptions, GetMatchedThreats } from './types'; + +export const buildThreatEnrichment = ({ + buildRuleMessage, + exceptionItems, + listClient, + logger, + services, + threatFilters, + threatIndex, + threatLanguage, + threatQuery, +}: BuildThreatEnrichmentOptions): SignalsEnrichment => { + const getMatchedThreats: GetMatchedThreats = async (ids) => { + const matchedThreatsFilter = { + query: { + bool: { + filter: { + ids: { values: ids }, + }, + }, + }, + }; + const threatResponse = await getThreatList({ + callCluster: services.callCluster, + exceptionItems, + threatFilters: [...threatFilters, matchedThreatsFilter], + query: threatQuery, + language: threatLanguage, + index: threatIndex, + listClient, + searchAfter: undefined, + sortField: undefined, + sortOrder: undefined, + logger, + buildRuleMessage, + perPage: undefined, + }); + + return threatResponse.hits.hits; + }; + + return (signals: SignalSearchResponse): Promise => + enrichSignalThreatMatches(signals, getMatchedThreats); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts index 12865e4dd47a90..266903f568792c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.mock.ts @@ -9,7 +9,7 @@ import { ThreatMapping } from '../../../../../common/detection_engine/schemas/ty import { Filter } from 'src/plugins/data/common'; import { SearchResponse } from 'elasticsearch'; -import { ThreatListItem } from './types'; +import { ThreatListDoc, ThreatListItem } from './types'; export const getThreatMappingMock = (): ThreatMapping => { return [ @@ -62,7 +62,7 @@ export const getThreatMappingMock = (): ThreatMapping => { ]; }; -export const getThreatListSearchResponseMock = (): SearchResponse => ({ +export const getThreatListSearchResponseMock = (): SearchResponse => ({ took: 0, timed_out: false, _shards: { @@ -74,33 +74,32 @@ export const getThreatListSearchResponseMock = (): SearchResponse ({ - '@timestamp': '2020-09-09T21:59:13Z', - host: { - name: 'host-1', - ip: '192.168.0.0.1', - }, - source: { - ip: '127.0.0.1', - port: 1, - }, - destination: { - ip: '127.0.0.1', - port: 1, +export const getThreatListItemMock = (overrides: Partial = {}): ThreatListItem => ({ + _id: '123', + _index: 'threat_index', + _type: '_doc', + _score: 0, + _source: { + '@timestamp': '2020-09-09T21:59:13Z', + host: { + name: 'host-1', + ip: '192.168.0.0.1', + }, + source: { + ip: '127.0.0.1', + port: 1, + }, + destination: { + ip: '127.0.0.1', + port: 1, + }, }, + fields: getThreatListItemFieldsMock(), + ...overrides, }); export const getThreatListItemFieldsMock = () => ({ @@ -188,13 +187,17 @@ export const getThreatMappingFilterShouldMock = (port = 1) => ({ filter: [ { bool: { - should: [{ match: { 'host.name': 'host-1' } }], + should: [ + { match: { 'host.name': { query: 'host-1', _name: expect.any(String) } } }, + ], minimum_should_match: 1, }, }, { bool: { - should: [{ match: { 'host.ip': '192.168.0.0.1' } }], + should: [ + { match: { 'host.ip': { query: '192.168.0.0.1', _name: expect.any(String) } } }, + ], minimum_should_match: 1, }, }, @@ -206,13 +209,19 @@ export const getThreatMappingFilterShouldMock = (port = 1) => ({ filter: [ { bool: { - should: [{ match: { 'destination.ip': '127.0.0.1' } }], + should: [ + { + match: { 'destination.ip': { query: '127.0.0.1', _name: expect.any(String) } }, + }, + ], minimum_should_match: 1, }, }, { bool: { - should: [{ match: { 'destination.port': port } }], + should: [ + { match: { 'destination.port': { query: port, _name: expect.any(String) } } }, + ], minimum_should_match: 1, }, }, @@ -224,7 +233,7 @@ export const getThreatMappingFilterShouldMock = (port = 1) => ({ filter: [ { bool: { - should: [{ match: { 'source.port': port } }], + should: [{ match: { 'source.port': { query: port, _name: expect.any(String) } } }], minimum_should_match: 1, }, }, @@ -236,7 +245,9 @@ export const getThreatMappingFilterShouldMock = (port = 1) => ({ filter: [ { bool: { - should: [{ match: { 'source.ip': '127.0.0.1' } }], + should: [ + { match: { 'source.ip': { query: '127.0.0.1', _name: expect.any(String) } } }, + ], minimum_should_match: 1, }, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts index 7a9c4b43b8f7af..1c0300ee0cc74e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts @@ -132,7 +132,7 @@ describe('build_threat_mapping_filter', () => { ], }, ], - threatListItem: { + threatListItem: getThreatListItemMock({ _source: { '@timestamp': '2020-09-09T21:59:13Z', host: { @@ -144,7 +144,7 @@ describe('build_threat_mapping_filter', () => { '@timestamp': ['2020-09-09T21:59:13Z'], 'host.name': ['host-1'], }, - }, + }), }); expect(item).toEqual([]); }); @@ -176,7 +176,7 @@ describe('build_threat_mapping_filter', () => { ], }, ], - threatListItem: { + threatListItem: getThreatListItemMock({ _source: { '@timestamp': '2020-09-09T21:59:13Z', host: { @@ -187,7 +187,7 @@ describe('build_threat_mapping_filter', () => { '@timestamp': ['2020-09-09T21:59:13Z'], 'host.name': ['host-1'], }, - }, + }), }); expect(item).toEqual([ { @@ -325,7 +325,10 @@ describe('build_threat_mapping_filter', () => { test('it should return an empty boolean clause given an empty object for a threat list item', () => { const threatMapping = getThreatMappingMock(); - const innerClause = createAndOrClauses({ threatMapping, threatListItem: { _source: {} } }); + const innerClause = createAndOrClauses({ + threatMapping, + threatListItem: getThreatListItemMock({ _source: {}, fields: {} }), + }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts index cab01a602b8a95..0a2789ec2f1d0d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts @@ -17,6 +17,7 @@ import { FilterThreatMappingOptions, SplitShouldClausesOptions, } from './types'; +import { encodeThreatMatchNamedQuery } from './utils'; export const MAX_CHUNK_SIZE = 1024; @@ -79,7 +80,14 @@ export const createInnerAndClauses = ({ should: [ { match: { - [threatMappingEntry.field]: value[0], + [threatMappingEntry.field]: { + query: value[0], + _name: encodeThreatMatchNamedQuery({ + id: threatListItem._id, + field: threatMappingEntry.field, + value: threatMappingEntry.value, + }), + }, }, }, ], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index a076ab46aae2a6..ba428bc0771250 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -14,6 +14,7 @@ import { SearchAfterAndBulkCreateReturnType } from '../types'; export const createThreatSignal = async ({ threatMapping, + threatEnrichment, query, inputIndex, type, @@ -77,6 +78,7 @@ export const createThreatSignal = async ({ `${threatFilter.query.bool.should.length} indicator items are being checked for existence of matches` ) ); + const result = await searchAfterAndBulkCreate({ gap, previousStartedAt, @@ -103,6 +105,7 @@ export const createThreatSignal = async ({ tags, throttle, buildRuleMessage, + enrichment: threatEnrichment, }); logger.debug( buildRuleMessage( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 1e486e58aa0730..7690eb5eb1d554 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -12,6 +12,7 @@ import { CreateThreatSignalsOptions } from './types'; import { createThreatSignal } from './create_threat_signal'; import { SearchAfterAndBulkCreateReturnType } from '../types'; import { combineConcurrentResults } from './utils'; +import { buildThreatEnrichment } from './build_threat_enrichment'; export const createThreatSignals = async ({ threatMapping, @@ -90,12 +91,25 @@ export const createThreatSignals = async ({ perPage, }); + const threatEnrichment = buildThreatEnrichment({ + buildRuleMessage, + exceptionItems, + listClient, + logger, + services, + threatFilters, + threatIndex, + threatLanguage, + threatQuery, + }); + while (threatList.hits.hits.length !== 0) { const chunks = chunk(itemsPerSearch, threatList.hits.hits); logger.debug(buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`)); const concurrentSearchesPerformed = chunks.map>( (slicedChunk) => createThreatSignal({ + threatEnrichment, threatMapping, query, inputIndex, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.mock.ts new file mode 100644 index 00000000000000..a3ff932e97886e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.mock.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SignalSearchResponse, SignalSourceHit } from '../types'; +import { ThreatMatchNamedQuery } from './types'; + +export const getNamedQueryMock = ( + overrides: Partial = {} +): ThreatMatchNamedQuery => ({ + id: 'id', + field: 'field', + value: 'value', + ...overrides, +}); + +export const getSignalHitMock = (overrides: Partial = {}): SignalSourceHit => ({ + _id: '_id', + _index: '_index', + _source: { + '@timestamp': '2020-11-20T15:35:28.373Z', + }, + _type: '_type', + _score: 0, + ...overrides, +}); + +export const getSignalsResponseMock = (signals: SignalSourceHit[] = []): SignalSearchResponse => ({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: signals.length, relation: 'eq' }, max_score: 0, hits: signals }, +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts new file mode 100644 index 00000000000000..3c0765b56ae20b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts @@ -0,0 +1,484 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get } from 'lodash'; + +import { getThreatListItemMock } from './build_threat_mapping_filter.mock'; +import { + buildMatchedIndicator, + enrichSignalThreatMatches, + groupAndMergeSignalMatches, +} from './enrich_signal_threat_matches'; +import { + getNamedQueryMock, + getSignalHitMock, + getSignalsResponseMock, +} from './enrich_signal_threat_matches.mock'; +import { GetMatchedThreats, ThreatListItem, ThreatMatchNamedQuery } from './types'; +import { encodeThreatMatchNamedQuery } from './utils'; + +describe('groupAndMergeSignalMatches', () => { + it('returns an empty array if there are no signals', () => { + expect(groupAndMergeSignalMatches([])).toEqual([]); + }); + + it('returns the same list if there are no duplicates', () => { + const signals = [getSignalHitMock({ _id: '1' }), getSignalHitMock({ _id: '2' })]; + const expectedSignals = [...signals]; + expect(groupAndMergeSignalMatches(signals)).toEqual(expectedSignals); + }); + + it('deduplicates signals with the same ID', () => { + const signals = [getSignalHitMock({ _id: '1' }), getSignalHitMock({ _id: '1' })]; + const expectedSignals = [signals[0]]; + expect(groupAndMergeSignalMatches(signals)).toEqual(expectedSignals); + }); + + it('merges the matched_queries of duplicate signals', () => { + const signals = [ + getSignalHitMock({ _id: '1', matched_queries: ['query1'] }), + getSignalHitMock({ _id: '1', matched_queries: ['query3', 'query4'] }), + ]; + const [mergedSignal] = groupAndMergeSignalMatches(signals); + expect(mergedSignal.matched_queries).toEqual(['query1', 'query3', 'query4']); + }); + + it('does not deduplicate identical named queries on duplicate signals', () => { + const signals = [ + getSignalHitMock({ _id: '1', matched_queries: ['query1'] }), + getSignalHitMock({ _id: '1', matched_queries: ['query1', 'query2'] }), + ]; + const [mergedSignal] = groupAndMergeSignalMatches(signals); + expect(mergedSignal.matched_queries).toEqual(['query1', 'query1', 'query2']); + }); + + it('merges the matched_queries of multiple signals', () => { + const signals = [ + getSignalHitMock({ _id: '1', matched_queries: ['query1'] }), + getSignalHitMock({ _id: '1', matched_queries: ['query3', 'query4'] }), + getSignalHitMock({ _id: '2', matched_queries: ['query1', 'query2'] }), + getSignalHitMock({ _id: '2', matched_queries: ['query5', 'query6'] }), + ]; + const mergedSignals = groupAndMergeSignalMatches(signals); + expect(mergedSignals.map((signal) => signal.matched_queries)).toEqual([ + ['query1', 'query3', 'query4'], + ['query1', 'query2', 'query5', 'query6'], + ]); + }); +}); + +describe('buildMatchedIndicator', () => { + let threats: ThreatListItem[]; + let queries: ThreatMatchNamedQuery[]; + + beforeEach(() => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + ]; + queries = [ + getNamedQueryMock({ id: '123', field: 'event.field', value: 'threat.indicator.domain' }), + ]; + }); + + it('returns an empty list if queries is empty', () => { + const indicators = buildMatchedIndicator({ + queries: [], + threats, + }); + + expect(indicators).toEqual([]); + }); + + it('returns the value of the matched indicator as matched.atomic', () => { + const [indicator] = buildMatchedIndicator({ + queries, + threats, + }); + + expect(get(indicator, 'matched.atomic')).toEqual('domain_1'); + }); + + it('returns the field of the matched indicator as matched.field', () => { + const [indicator] = buildMatchedIndicator({ + queries, + threats, + }); + + expect(get(indicator, 'matched.field')).toEqual('event.field'); + }); + + it('returns the type of the matched indicator as matched.type', () => { + const [indicator] = buildMatchedIndicator({ + queries, + threats, + }); + + expect(get(indicator, 'matched.type')).toEqual('type_1'); + }); + + it('returns indicators for each provided query', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + getThreatListItemMock({ + _id: '456', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + ]; + queries = [ + getNamedQueryMock({ id: '123', value: 'threat.indicator.domain' }), + getNamedQueryMock({ id: '456', value: 'threat.indicator.other' }), + getNamedQueryMock({ id: '456', value: 'threat.indicator.domain' }), + ]; + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toHaveLength(queries.length); + }); + + it('returns the indicator data specified at threat.indicator by default', () => { + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toEqual([ + { + domain: 'domain_1', + matched: { + atomic: 'domain_1', + field: 'event.field', + type: 'type_1', + }, + other: 'other_1', + type: 'type_1', + }, + ]); + }); + + it('returns the indicator data specified at the custom path', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + 'threat.indicator.domain': 'domain_1', + custom: { + indicator: { + path: { + indicator_field: 'indicator_field_1', + type: 'indicator_type', + }, + }, + }, + }, + }), + ]; + + const indicators = buildMatchedIndicator({ + indicatorPath: 'custom.indicator.path', + queries, + threats, + }); + + expect(indicators).toEqual([ + { + indicator_field: 'indicator_field_1', + matched: { + atomic: 'domain_1', + field: 'event.field', + type: 'indicator_type', + }, + type: 'indicator_type', + }, + ]); + }); + + it('returns only the match data if indicator field is absent', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: {}, + }), + ]; + + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toEqual([ + { + matched: { + atomic: undefined, + field: 'event.field', + type: undefined, + }, + }, + ]); + }); + + it('returns only the match data if indicator field is an empty array', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { threat: { indicator: [] } }, + }), + ]; + + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toEqual([ + { + matched: { + atomic: undefined, + field: 'event.field', + type: undefined, + }, + }, + ]); + }); + + it('returns data sans atomic from first indicator if indicator field is an array of objects', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { + indicator: [ + { domain: 'foo', type: 'first' }, + { domain: 'bar', type: 'second' }, + ], + }, + }, + }), + ]; + + const indicators = buildMatchedIndicator({ + queries, + threats, + }); + + expect(indicators).toEqual([ + { + domain: 'foo', + matched: { + atomic: undefined, + field: 'event.field', + type: 'first', + }, + type: 'first', + }, + ]); + }); + + it('throws an error if indicator field is a not an object', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { + indicator: 'not an object', + }, + }, + }), + ]; + + expect(() => + buildMatchedIndicator({ + queries, + threats, + }) + ).toThrowError('Expected indicator field to be an object, but found: not an object'); + }); + + it('throws an error if indicator field is not an array of objects', () => { + threats = [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { + indicator: ['not an object'], + }, + }, + }), + ]; + + expect(() => + buildMatchedIndicator({ + queries, + threats, + }) + ).toThrowError('Expected indicator field to be an object, but found: not an object'); + }); +}); + +describe('enrichSignalThreatMatches', () => { + let getMatchedThreats: GetMatchedThreats; + let matchedQuery: string; + + beforeEach(() => { + getMatchedThreats = async () => [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + ]; + matchedQuery = encodeThreatMatchNamedQuery( + getNamedQueryMock({ id: '123', field: 'event.field', value: 'threat.indicator.domain' }) + ); + }); + + it('performs no enrichment if there are no signals', async () => { + const signals = getSignalsResponseMock([]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + + expect(enrichedSignals.hits.hits).toEqual([]); + }); + + it('preserves existing threat.indicator objects on signals', async () => { + const signalHit = getSignalHitMock({ + _source: { '@timestamp': 'mocked', threat: { indicator: [{ existing: 'indicator' }] } }, + matched_queries: [matchedQuery], + }); + const signals = getSignalsResponseMock([signalHit]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + const [enrichedHit] = enrichedSignals.hits.hits; + const indicators = get(enrichedHit._source, 'threat.indicator'); + + expect(indicators).toEqual([ + { existing: 'indicator' }, + { + domain: 'domain_1', + matched: { atomic: 'domain_1', field: 'event.field', type: 'type_1' }, + other: 'other_1', + type: 'type_1', + }, + ]); + }); + + it('provides only match data if the matched threat cannot be found', async () => { + getMatchedThreats = async () => []; + const signalHit = getSignalHitMock({ + matched_queries: [matchedQuery], + }); + const signals = getSignalsResponseMock([signalHit]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + const [enrichedHit] = enrichedSignals.hits.hits; + const indicators = get(enrichedHit._source, 'threat.indicator'); + + expect(indicators).toEqual([ + { + matched: { atomic: undefined, field: 'event.field', type: undefined }, + }, + ]); + }); + + it('preserves an existing threat.indicator object on signals', async () => { + const signalHit = getSignalHitMock({ + _source: { '@timestamp': 'mocked', threat: { indicator: { existing: 'indicator' } } }, + matched_queries: [matchedQuery], + }); + const signals = getSignalsResponseMock([signalHit]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + const [enrichedHit] = enrichedSignals.hits.hits; + const indicators = get(enrichedHit._source, 'threat.indicator'); + + expect(indicators).toEqual([ + { existing: 'indicator' }, + { + domain: 'domain_1', + matched: { atomic: 'domain_1', field: 'event.field', type: 'type_1' }, + other: 'other_1', + type: 'type_1', + }, + ]); + }); + + it('throws an error if threat is neither an object nor undefined', async () => { + const signalHit = getSignalHitMock({ + _source: { '@timestamp': 'mocked', threat: 'whoops' }, + matched_queries: [matchedQuery], + }); + const signals = getSignalsResponseMock([signalHit]); + await expect(() => enrichSignalThreatMatches(signals, getMatchedThreats)).rejects.toThrowError( + 'Expected threat field to be an object, but found: whoops' + ); + }); + + it('merges duplicate matched signals into a single signal with multiple indicators', async () => { + getMatchedThreats = async () => [ + getThreatListItemMock({ + _id: '123', + _source: { + threat: { indicator: { domain: 'domain_1', other: 'other_1', type: 'type_1' } }, + }, + }), + getThreatListItemMock({ + _id: '456', + _source: { + threat: { indicator: { domain: 'domain_2', other: 'other_2', type: 'type_2' } }, + }, + }), + ]; + const signalHit = getSignalHitMock({ + _id: 'signal123', + matched_queries: [matchedQuery], + }); + const otherSignalHit = getSignalHitMock({ + _id: 'signal123', + matched_queries: [ + encodeThreatMatchNamedQuery( + getNamedQueryMock({ id: '456', field: 'event.other', value: 'threat.indicator.domain' }) + ), + ], + }); + const signals = getSignalsResponseMock([signalHit, otherSignalHit]); + const enrichedSignals = await enrichSignalThreatMatches(signals, getMatchedThreats); + expect(enrichedSignals.hits.total).toEqual(expect.objectContaining({ value: 1 })); + expect(enrichedSignals.hits.hits).toHaveLength(1); + + const [enrichedHit] = enrichedSignals.hits.hits; + const indicators = get(enrichedHit._source, 'threat.indicator'); + + expect(indicators).toEqual([ + { + domain: 'domain_1', + matched: { atomic: 'domain_1', field: 'event.field', type: 'type_1' }, + other: 'other_1', + type: 'type_1', + }, + { + domain: 'domain_2', + matched: { + atomic: 'domain_2', + field: 'event.other', + type: 'type_2', + }, + other: 'other_2', + type: 'type_2', + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts new file mode 100644 index 00000000000000..c298ef98ebcd53 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get, isObject } from 'lodash'; + +import type { SignalSearchResponse, SignalSourceHit } from '../types'; +import type { + GetMatchedThreats, + ThreatIndicator, + ThreatListItem, + ThreatMatchNamedQuery, +} from './types'; +import { extractNamedQueries } from './utils'; + +const DEFAULT_INDICATOR_PATH = 'threat.indicator'; +const getSignalId = (signal: SignalSourceHit): string => signal._id; + +export const groupAndMergeSignalMatches = (signalHits: SignalSourceHit[]): SignalSourceHit[] => { + const dedupedHitsMap = signalHits.reduce>((acc, signalHit) => { + const signalId = getSignalId(signalHit); + const existingSignalHit = acc[signalId]; + + if (existingSignalHit == null) { + acc[signalId] = signalHit; + } else { + const existingQueries = existingSignalHit?.matched_queries ?? []; + const newQueries = signalHit.matched_queries ?? []; + existingSignalHit.matched_queries = [...existingQueries, ...newQueries]; + + acc[signalId] = existingSignalHit; + } + + return acc; + }, {}); + const dedupedHits = Object.values(dedupedHitsMap); + return dedupedHits; +}; + +export const buildMatchedIndicator = ({ + queries, + threats, + indicatorPath = DEFAULT_INDICATOR_PATH, +}: { + queries: ThreatMatchNamedQuery[]; + threats: ThreatListItem[]; + indicatorPath?: string; +}): ThreatIndicator[] => + queries.map((query) => { + const matchedThreat = threats.find((threat) => threat._id === query.id); + const indicatorValue = get(matchedThreat?._source, indicatorPath) as unknown; + const indicator = [indicatorValue].flat()[0] ?? {}; + if (!isObject(indicator)) { + throw new Error(`Expected indicator field to be an object, but found: ${indicator}`); + } + const atomic = get(matchedThreat?._source, query.value) as unknown; + const type = get(indicator, 'type') as unknown; + + return { + ...indicator, + matched: { atomic, field: query.field, type }, + }; + }); + +export const enrichSignalThreatMatches = async ( + signals: SignalSearchResponse, + getMatchedThreats: GetMatchedThreats +): Promise => { + const signalHits = signals.hits.hits; + if (signalHits.length === 0) { + return signals; + } + + const uniqueHits = groupAndMergeSignalMatches(signalHits); + const signalMatches = uniqueHits.map((signalHit) => extractNamedQueries(signalHit)); + const matchedThreatIds = [...new Set(signalMatches.flat().map(({ id }) => id))]; + const matchedThreats = await getMatchedThreats(matchedThreatIds); + const matchedIndicators = signalMatches.map((queries) => + buildMatchedIndicator({ queries, threats: matchedThreats }) + ); + + const enrichedSignals: SignalSourceHit[] = uniqueHits.map((signalHit, i) => { + const threat = get(signalHit._source, 'threat') ?? {}; + if (!isObject(threat)) { + throw new Error(`Expected threat field to be an object, but found: ${threat}`); + } + const existingIndicatorValue = get(signalHit._source, 'threat.indicator') ?? []; + const existingIndicators = [existingIndicatorValue].flat(); // ensure indicators is an array + + return { + ...signalHit, + _source: { + ...signalHit._source, + threat: { + ...threat, + indicator: [...existingIndicators, ...matchedIndicators[i]], + }, + }, + }; + }); + /* eslint-disable require-atomic-updates */ + signals.hits.hits = enrichedSignals; + if (isObject(signals.hits.total)) { + signals.hits.total.value = enrichedSignals.length; + } else { + signals.hits.total = enrichedSignals.length; + } + /* eslint-enable require-atomic-updates */ + + return signals; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index 26e42b795be3ea..b80d3faf9b61c7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -5,7 +5,9 @@ * 2.0. */ +import { SearchResponse } from 'elasticsearch'; import { Duration } from 'moment'; + import { ListClient } from '../../../../../../lists/server'; import { Type, @@ -31,7 +33,7 @@ import { ILegacyScopedClusterClient, Logger } from '../../../../../../../../src/ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; -import { SearchAfterAndBulkCreateReturnType } from '../types'; +import { SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; export type SortOrderOrUndefined = 'asc' | 'desc' | undefined; @@ -76,6 +78,7 @@ export interface CreateThreatSignalsOptions { export interface CreateThreatSignalOptions { threatMapping: ThreatMapping; + threatEnrichment: SignalsEnrichment; query: string; inputIndex: string[]; type: Type; @@ -177,14 +180,40 @@ export interface GetSortWithTieBreakerOptions { listItemIndex: string; } +export interface ThreatListDoc { + [key: string]: unknown; +} + /** * This is an ECS document being returned, but the user could return or use non-ecs based * documents potentially. */ -export interface ThreatListItem { +export type ThreatListItem = SearchResponse['hits']['hits'][number]; + +export interface ThreatIndicator { [key: string]: unknown; } export interface SortWithTieBreaker { [key: string]: string; } + +export interface ThreatMatchNamedQuery { + id: string; + field: string; + value: string; +} + +export type GetMatchedThreats = (ids: string[]) => Promise; + +export interface BuildThreatEnrichmentOptions { + buildRuleMessage: BuildRuleMessage; + exceptionItems: ExceptionListItemSchema[]; + listClient: ListClient; + logger: Logger; + services: AlertServices; + threatFilters: PartialFilter[]; + threatIndex: ThreatIndex; + threatLanguage: ThreatLanguageOrUndefined; + threatQuery: ThreatQuery; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts index a738c8a864a1c6..897143f9ae5744 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts @@ -7,6 +7,7 @@ import { SearchAfterAndBulkCreateReturnType } from '../types'; import { sampleSignalHit } from '../__mocks__/es_results'; +import { ThreatMatchNamedQuery } from './types'; import { calculateAdditiveMax, @@ -14,6 +15,8 @@ import { calculateMaxLookBack, combineConcurrentResults, combineResults, + decodeThreatMatchNamedQuery, + encodeThreatMatchNamedQuery, } from './utils'; describe('utils', () => { @@ -580,4 +583,56 @@ describe('utils', () => { ); }); }); + + describe('threat match queries', () => { + describe('encodeThreatMatchNamedQuery()', () => { + it('generates a string that can be later decoded', () => { + const encoded = encodeThreatMatchNamedQuery({ + id: 'id', + field: 'field', + value: 'value', + }); + + expect(typeof encoded).toEqual('string'); + }); + }); + + describe('decodeThreatMatchNamedQuery()', () => { + it('can decode an encoded query', () => { + const query: ThreatMatchNamedQuery = { + id: 'my_id', + field: 'threat.indicator.domain', + value: 'host.name', + }; + + const encoded = encodeThreatMatchNamedQuery(query); + const decoded = decodeThreatMatchNamedQuery(encoded); + + expect(decoded).not.toBe(query); + expect(decoded).toEqual(query); + }); + + it('raises an error if the input is invalid', () => { + const badInput = 'nope'; + + expect(() => decodeThreatMatchNamedQuery(badInput)).toThrowError( + 'Decoded query is invalid. Decoded value: {"id":"nope"}' + ); + }); + + it('raises an error if the query is missing a value', () => { + const badQuery: ThreatMatchNamedQuery = { + id: 'my_id', + // @ts-expect-error field intentionally undefined + field: undefined, + value: 'host.name', + }; + const badInput = encodeThreatMatchNamedQuery(badQuery); + + expect(() => decodeThreatMatchNamedQuery(badInput)).toThrowError( + 'Decoded query is invalid. Decoded value: {"id":"my_id","field":"","value":"host.name"}' + ); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts index 87bcb657a53a52..72d9257798e1c9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { SearchAfterAndBulkCreateReturnType } from '../types'; +import { SearchAfterAndBulkCreateReturnType, SignalSourceHit } from '../types'; +import { ThreatMatchNamedQuery } from './types'; /** * Given two timers this will take the max of each and add them to each other and return that addition. @@ -113,3 +114,28 @@ export const combineConcurrentResults = ( return combineResults(currentResult, maxedNewResult); }; + +const separator = '___SEPARATOR___'; +export const encodeThreatMatchNamedQuery = ({ + id, + field, + value, +}: ThreatMatchNamedQuery): string => { + return [id, field, value].join(separator); +}; + +export const decodeThreatMatchNamedQuery = (encoded: string): ThreatMatchNamedQuery => { + const queryValues = encoded.split(separator); + const [id, field, value] = queryValues; + const query = { id, field, value }; + + if (queryValues.length !== 3 || !queryValues.every(Boolean)) { + const queryString = JSON.stringify(query); + throw new Error(`Decoded query is invalid. Decoded value: ${queryString}`); + } + + return query; +}; + +export const extractNamedQueries = (hit: SignalSourceHit): ThreatMatchNamedQuery[] => + hit.matched_queries?.map((match) => decodeThreatMatchNamedQuery(match)) ?? []; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold_find_previous_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold_find_previous_signals.ts index a623608ef60064..dbad1d12d2be63 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold_find_previous_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold_find_previous_signals.ts @@ -48,6 +48,7 @@ export const findPreviousThresholdSignals = async ({ threshold: { terms: { field: 'signal.threshold_result.value', + size: 10000, }, aggs: { lastSignalTimestamp: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 8031b81f70eb05..f7ac0425b2f2e4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -228,6 +228,8 @@ export interface QueryFilter { }; } +export type SignalsEnrichment = (signals: SignalSearchResponse) => Promise; + export interface SearchAfterAndBulkCreateParams { gap: moment.Duration | null; previousStartedAt: Date | null | undefined; @@ -254,6 +256,7 @@ export interface SearchAfterAndBulkCreateParams { tags: string[]; throttle: string; buildRuleMessage: BuildRuleMessage; + enrichment?: SignalsEnrichment; } export interface SearchAfterAndBulkCreateReturnType { diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 164ccfd7389194..5003d49136b7ca 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -116,6 +116,14 @@ const securitySubPlugins = [ `${APP_ID}:${SecurityPageName.administration}`, ]; +const caseSavedObjects = [ + 'cases', + 'cases-comments', + 'cases-sub-case', + 'cases-configure', + 'cases-user-actions', +]; + export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config: ConfigType; @@ -215,10 +223,7 @@ export class Plugin implements IPlugin { const savedObjectToCopy = { type: 'dashboard', id: 'my-dash', - references: [ - { - type: 'visualization', - id: 'my-viz', - name: 'My Viz', - }, - ], - meta: { icon: 'dashboard', title: 'foo', namespaceType: 'single' }, - } as SavedObjectsManagementRecord; + namespaces: ['default'], + icon: 'dashboard', + title: 'foo', + } as SavedObjectTarget; const wrapper = mountWithIntl( { }; describe('CopyToSpaceFlyout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - it('waits for spaces to load', async () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx index abf7f7fe40e8de..c86a7c92993a26 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { EuiFlyout, EuiIcon, @@ -27,18 +27,17 @@ import { ToastsStart } from 'src/core/public'; import { ProcessedImportResponse, processImportResponse, - SavedObjectsManagementRecord, } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; import { SpacesManager } from '../../spaces_manager'; import { ProcessingCopyToSpace } from './processing_copy_to_space'; import { CopyToSpaceFlyoutFooter } from './copy_to_space_flyout_footer'; import { CopyToSpaceForm } from './copy_to_space_form'; -import { CopyOptions, ImportRetry } from '../types'; +import { CopyOptions, ImportRetry, SavedObjectTarget } from '../types'; interface Props { onClose: () => void; - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: SavedObjectTarget; spacesManager: SpacesManager; toastNotifications: ToastsStart; } @@ -48,7 +47,17 @@ const CREATE_NEW_COPIES_DEFAULT = true; const OVERWRITE_ALL_DEFAULT = true; export const CopySavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, savedObject, spacesManager, toastNotifications } = props; + const { onClose, savedObjectTarget: object, spacesManager, toastNotifications } = props; + const savedObjectTarget = useMemo( + () => ({ + type: object.type, + id: object.id, + namespaces: object.namespaces, + icon: object.icon || 'apps', + title: object.title || `${object.type} [id=${object.id}]`, + }), + [object] + ); const [copyOptions, setCopyOptions] = useState({ includeRelated: INCLUDE_RELATED_DEFAULT, createNewCopies: CREATE_NEW_COPIES_DEFAULT, @@ -100,7 +109,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setCopyResult({}); try { const copySavedObjectsResult = await spacesManager.copySavedObjects( - [{ type: savedObject.type, id: savedObject.id }], + [{ type: savedObjectTarget.type, id: savedObjectTarget.id }], copyOptions.selectedSpaceIds, copyOptions.includeRelated, copyOptions.createNewCopies, @@ -160,7 +169,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { setConflictResolutionInProgress(true); try { await spacesManager.resolveCopySavedObjectsErrors( - [{ type: savedObject.type, id: savedObject.id }], + [{ type: savedObjectTarget.type, id: savedObjectTarget.id }], retries, copyOptions.includeRelated, copyOptions.createNewCopies @@ -220,7 +229,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => { if (!copyInProgress) { return ( { // Step3: Copy operation is in progress return ( { - + -

{savedObject.meta.title}

+

{savedObjectTarget.title}

diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx index 5bf171874d5a89..6c0ab695d94d8f 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_form.tsx @@ -8,27 +8,26 @@ import React from 'react'; import { EuiSpacer, EuiTitle, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CopyOptions } from '../types'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { CopyOptions, SavedObjectTarget } from '../types'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { CopyModeControl, CopyMode } from './copy_mode_control'; interface Props { - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: Required; spaces: Space[]; onUpdate: (copyOptions: CopyOptions) => void; copyOptions: CopyOptions; } export const CopyToSpaceForm = (props: Props) => { - const { savedObject, spaces, onUpdate, copyOptions } = props; + const { savedObjectTarget, spaces, onUpdate, copyOptions } = props; // if the user is not creating new copies, prevent them from copying objects an object into a space where it already exists const getDisabledSpaceIds = (createNewCopies: boolean) => createNewCopies ? new Set() - : (savedObject.namespaces ?? []).reduce((acc, cur) => acc.add(cur), new Set()); + : savedObjectTarget.namespaces.reduce((acc, cur) => acc.add(cur), new Set()); const changeCopyMode = ({ createNewCopies, overwrite }: CopyMode) => { const disabled = getDisabledSpaceIds(createNewCopies); diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx index b30e996dbd0c1d..08c72b595a61d4 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/processing_copy_to_space.tsx @@ -14,17 +14,14 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - ProcessedImportResponse, - SavedObjectsManagementRecord, -} from 'src/plugins/saved_objects_management/public'; +import { ProcessedImportResponse } from 'src/plugins/saved_objects_management/public'; import { Space } from '../../../../../../src/plugins/spaces_oss/common'; -import { CopyOptions, ImportRetry } from '../types'; +import { CopyOptions, ImportRetry, SavedObjectTarget } from '../types'; import { SpaceResult, SpaceResultProcessing } from './space_result'; import { summarizeCopyResult } from '..'; interface Props { - savedObject: SavedObjectsManagementRecord; + savedObjectTarget: Required; copyInProgress: boolean; conflictResolutionInProgress: boolean; copyResult: Record; @@ -98,7 +95,10 @@ export const ProcessingCopyToSpace = (props: Props) => { {props.copyOptions.selectedSpaceIds.map((id) => { const space = props.spaces.find((s) => s.id === id) as Space; const spaceCopyResult = props.copyResult[space.id]; - const summarizedSpaceCopyResult = summarizeCopyResult(props.savedObject, spaceCopyResult); + const summarizedSpaceCopyResult = summarizeCopyResult( + props.savedObjectTarget, + spaceCopyResult + ); return ( @@ -106,7 +106,6 @@ export const ProcessingCopyToSpace = (props: Props) => { ) : ( { summarizedCopyResult, retries, onRetriesChange, - savedObject, conflictResolutionInProgress, } = props; const { objects } = summarizedCopyResult; @@ -109,7 +106,6 @@ export const SpaceResult = (props: Props) => { > diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts index 346bafceabf662..525efc4158f728 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.test.ts @@ -11,6 +11,7 @@ import { FailedImport, SavedObjectsManagementRecord, } from 'src/plugins/saved_objects_management/public'; +import { SavedObjectTarget } from './types'; // Sample data references: // @@ -21,6 +22,13 @@ import { // Dashboard has references to visualizations, and transitive references to index patterns const OBJECTS = { + COPY_TARGET: { + type: 'dashboard', + id: 'foo', + namespaces: [], + icon: 'dashboardApp', + title: 'my-dashboard-title', + } as Required, MY_DASHBOARD: { type: 'dashboard', id: 'foo', @@ -132,7 +140,7 @@ const createCopyResult = ( describe('summarizeCopyResult', () => { it('indicates the result is processing when not provided', () => { const copyResult = undefined; - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -155,7 +163,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract conflicts, including transitive conflicts', () => { const copyResult = createCopyResult({ withConflicts: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -235,7 +243,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract missing references errors', () => { const copyResult = createCopyResult({ withMissingReferencesError: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -292,7 +300,7 @@ describe('summarizeCopyResult', () => { it('processes failedImports to extract unresolvable errors', () => { const copyResult = createCopyResult({ withUnresolvableError: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -359,7 +367,7 @@ describe('summarizeCopyResult', () => { it('processes a result without errors', () => { const copyResult = createCopyResult(); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult).toMatchInlineSnapshot(` Object { @@ -426,7 +434,7 @@ describe('summarizeCopyResult', () => { it('indicates when successes and failures have been overwritten', () => { const copyResult = createCopyResult({ withMissingReferencesError: true, overwrite: true }); - const summarizedResult = summarizeCopyResult(OBJECTS.MY_DASHBOARD, copyResult); + const summarizedResult = summarizeCopyResult(OBJECTS.COPY_TARGET, copyResult); expect(summarizedResult.objects).toHaveLength(4); for (const obj of summarizedResult.objects) { diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts index 1e5282436a4913..0986f5723a6dee 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/summarize_copy_result.ts @@ -5,15 +5,12 @@ * 2.0. */ -import { - SavedObjectsManagementRecord, - ProcessedImportResponse, - FailedImport, -} from 'src/plugins/saved_objects_management/public'; +import { ProcessedImportResponse, FailedImport } from 'src/plugins/saved_objects_management/public'; import { SavedObjectsImportConflictError, SavedObjectsImportAmbiguousConflictError, } from 'kibana/public'; +import { SavedObjectTarget } from './types'; export interface SummarizedSavedObjectResult { type: string; @@ -67,7 +64,7 @@ export type SummarizedCopyToSpaceResult = | ProcessingResponse; export function summarizeCopyResult( - savedObject: SavedObjectsManagementRecord, + savedObjectTarget: Required, copyResult: ProcessedImportResponse | undefined ): SummarizedCopyToSpaceResult { const conflicts = copyResult?.failedImports.filter(isAnyConflict) ?? []; @@ -95,12 +92,12 @@ export function summarizeCopyResult( }; const objectMap = new Map(); - objectMap.set(`${savedObject.type}:${savedObject.id}`, { - type: savedObject.type, - id: savedObject.id, - name: savedObject.meta.title, - icon: savedObject.meta.icon, - ...getExtraFields(savedObject), + objectMap.set(`${savedObjectTarget.type}:${savedObjectTarget.id}`, { + type: savedObjectTarget.type, + id: savedObjectTarget.id, + name: savedObjectTarget.title, + icon: savedObjectTarget.icon, + ...getExtraFields(savedObjectTarget), }); const addObjectsToMap = ( diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts index 1e3293df8f2581..676b8ee4607518 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/types.ts @@ -19,3 +19,30 @@ export type ImportRetry = Omit; export interface CopySavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } + +export interface SavedObjectTarget { + /** + * The object's type. + */ + type: string; + /** + * The object's ID. + */ + id: string; + /** + * The namespaces that the object currently exists in. + */ + namespaces: string[]; + /** + * The EUI icon that is rendered in the flyout's subtitle. + * + * Default is 'apps'. + */ + icon?: string; + /** + * The string that is rendered in the flyout's subtitle. + * + * Default is `${type} [id=${id}]`. + */ + title?: string; +} diff --git a/x-pack/plugins/spaces/public/index.ts b/x-pack/plugins/spaces/public/index.ts index a87b953f08c62e..3620ae757052da 100644 --- a/x-pack/plugins/spaces/public/index.ts +++ b/x-pack/plugins/spaces/public/index.ts @@ -11,9 +11,7 @@ export { SpaceAvatar, getSpaceColor, getSpaceImageUrl, getSpaceInitials } from ' export { SpacesPluginSetup, SpacesPluginStart } from './plugin'; -export { SpacesManager } from './spaces_manager'; - -export { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../common'; +export type { GetAllSpacesPurpose, GetSpaceResult } from '../common'; // re-export types from oss definition export type { Space } from '../../../../src/plugins/spaces_oss/common'; diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 151157180ae491..2d02d4a3b98d81 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { CoreSetup, CoreStart, Plugin, StartServicesAccessor } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { SpacesOssPluginSetup, SpacesApi } from 'src/plugins/spaces_oss/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; @@ -20,6 +20,7 @@ import { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space' import { AdvancedSettingsService } from './advanced_settings'; import { ManagementService } from './management'; import { spaceSelectorApp } from './space_selector'; +import { getUiApi } from './ui_api'; export interface PluginsSetup { spacesOss: SpacesOssPluginSetup; @@ -39,11 +40,20 @@ export type SpacesPluginStart = ReturnType; export class SpacesPlugin implements Plugin { private spacesManager!: SpacesManager; + private spacesApi!: SpacesApi; private managementService?: ManagementService; - public setup(core: CoreSetup<{}, SpacesPluginStart>, plugins: PluginsSetup) { + public setup(core: CoreSetup, plugins: PluginsSetup) { this.spacesManager = new SpacesManager(core.http); + this.spacesApi = { + ui: getUiApi({ + spacesManager: this.spacesManager, + getStartServices: core.getStartServices, + }), + activeSpace$: this.spacesManager.onActiveSpaceChange$, + getActiveSpace: () => this.spacesManager.getActiveSpace(), + }; if (plugins.home) { plugins.home.featureCatalogue.register(createSpacesFeatureCatalogueEntry()); @@ -53,7 +63,7 @@ export class SpacesPlugin implements Plugin, + getStartServices: core.getStartServices, spacesManager: this.spacesManager, }); } @@ -69,10 +79,8 @@ export class SpacesPlugin implements Plugin, + spacesApiUi: this.spacesApi.ui, }); const copySavedObjectsToSpaceService = new CopySavedObjectsToSpaceService(); copySavedObjectsToSpaceService.setup({ @@ -88,7 +96,7 @@ export class SpacesPlugin implements Plugin this.spacesManager.getActiveSpace(), - }; - } } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts new file mode 100644 index 00000000000000..ef3248e1cd60a8 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const DEFAULT_OBJECT_NOUN = i18n.translate('xpack.spaces.shareToSpace.objectNoun', { + defaultMessage: 'object', +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx deleted file mode 100644 index 17132d291a612a..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/context_wrapper.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState, useEffect, PropsWithChildren } from 'react'; -import { StartServicesAccessor, CoreStart } from 'src/core/public'; -import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; -import { PluginsStart } from '../../plugin'; - -interface Props { - getStartServices: StartServicesAccessor; -} - -export const ContextWrapper = (props: PropsWithChildren) => { - const { getStartServices, children } = props; - - const [coreStart, setCoreStart] = useState(); - - useEffect(() => { - getStartServices().then((startServices) => { - const [coreStartValue] = startServices; - setCoreStart(coreStartValue); - }); - }, [getStartServices]); - - if (!coreStart) { - return null; - } - - const { application, docLinks } = coreStart; - const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ - application, - docLinks, - }); - - return {children}; -}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts index 1fca0980e9d8bc..b133be833d505e 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/index.ts @@ -5,5 +5,6 @@ * 2.0. */ -export { ContextWrapper } from './context_wrapper'; -export { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; +export { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; +export { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; +export { getLegacyUrlConflict } from './legacy_url_conflict'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx new file mode 100644 index 00000000000000..b9a01d4deabb57 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { LegacyUrlConflictProps } from 'src/plugins/spaces_oss/public'; +import { LegacyUrlConflictInternal, InternalProps } from './legacy_url_conflict_internal'; + +export const getLegacyUrlConflict = ( + internalProps: InternalProps +): React.FC => { + return (props: LegacyUrlConflictProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx new file mode 100644 index 00000000000000..1b897e8afa7d2f --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { EuiCallOut } from '@elastic/eui'; +import { mountWithIntl, findTestSubject } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { LegacyUrlConflictInternal } from './legacy_url_conflict_internal'; + +const APP_ID = 'testAppId'; +const PATH = 'path'; + +describe('LegacyUrlConflict', () => { + const setup = async () => { + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + const subject = new BehaviorSubject(`not-${APP_ID}`); + subject.next(APP_ID); // test below asserts that the consumer received the most recent APP_ID + startServices.application.currentAppId$ = subject; + const application = startServices.application; + getStartServices.mockResolvedValue([startServices, , ,]); + + const wrapper = mountWithIntl( + + ); + + // wait for wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return { wrapper, application }; + }; + + it('can click the "Go to other object" button', async () => { + const { wrapper, application } = await setup(); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + + const goToOtherButton = findTestSubject(wrapper, 'legacy-url-conflict-go-to-other-button'); + goToOtherButton.simulate('click'); + + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { path: PATH }); + }); + + it('can click the "Dismiss" button', async () => { + const { wrapper } = await setup(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); // callout is visible + + const dismissButton = findTestSubject(wrapper, 'legacy-url-conflict-dismiss-button'); + dismissButton.simulate('click'); + wrapper.update(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(0); // callout is not visible + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx new file mode 100644 index 00000000000000..1157725c69ee2d --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/legacy_url_conflict_internal.tsx @@ -0,0 +1,114 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { firstValueFrom } from '@kbn/std'; +import React, { useState, useEffect } from 'react'; +import type { ApplicationStart, StartServicesAccessor } from 'src/core/public'; +import type { LegacyUrlConflictProps } from 'src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../../plugin'; +import { DEFAULT_OBJECT_NOUN } from './constants'; + +export interface InternalProps { + getStartServices: StartServicesAccessor; +} + +export const LegacyUrlConflictInternal = (props: InternalProps & LegacyUrlConflictProps) => { + const { + getStartServices, + objectNoun = DEFAULT_OBJECT_NOUN, + currentObjectId, + otherObjectId, + otherObjectPath, + } = props; + + const [applicationStart, setApplicationStart] = useState(); + const [isDismissed, setIsDismissed] = useState(false); + const [appId, setAppId] = useState(); + + useEffect(() => { + async function setup() { + const [{ application }] = await getStartServices(); + const appIdValue = await firstValueFrom(application.currentAppId$); // retrieve the most recent value from the BehaviorSubject + setApplicationStart(application); + setAppId(appIdValue); + } + setup(); + }, [getStartServices]); + + if (!applicationStart || !appId || isDismissed) { + return null; + } + + function clickLinkButton() { + applicationStart!.navigateToApp(appId!, { path: otherObjectPath }); + } + + function clickDismissButton() { + setIsDismissed(true); + } + + return ( + + } + > + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx index 678464bcf4d649..46610a2cc9a7c5 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -26,7 +26,7 @@ export const NoSpacesAvailable = (props: Props) => { { href={getUrlForApp('management', { path: 'kibana/spaces/create' })} > diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 707f60d5979a1a..1b5870b8b540dc 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -22,58 +22,82 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { NoSpacesAvailable } from './no_spaces_available'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; -import { SpaceTarget } from '../types'; +import { ShareToSpaceTarget } from '../../types'; +import { useSpaces } from '../../spaces_context'; +import { ShareOptions } from '../types'; interface Props { - spaces: SpaceTarget[]; - selectedSpaceIds: string[]; + spaces: ShareToSpaceTarget[]; + shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; const ROW_HEIGHT = 40; -const partiallyAuthorizedTooltip = { - checked: i18n.translate( - 'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked', - { defaultMessage: 'You need additional privileges to deselect this space.' } - ), - unchecked: i18n.translate( - 'xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked', - { defaultMessage: 'You need additional privileges to select this space.' } - ), -}; -const partiallyAuthorizedSpaceProps = (checked: boolean) => ({ - append: ( - - ), - disabled: true, -}); -const activeSpaceProps = { - append: Current, - disabled: true, - checked: 'on' as 'on', -}; +const APPEND_ACTIVE_SPACE = ( + + {i18n.translate('xpack.spaces.shareToSpace.currentSpaceBadge', { defaultMessage: 'Current' })} + +); +const APPEND_CANNOT_SELECT = ( + +); +const APPEND_CANNOT_DESELECT = ( + +); +const APPEND_FEATURE_IS_DISABLED = ( + +); export const SelectableSpacesControl = (props: Props) => { - const { spaces, selectedSpaceIds, onChange } = props; - const { services } = useKibana(); + const { + spaces, + shareOptions, + onChange, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; + const { services } = useSpaces(); const { application, docLinks } = services; + const { selectedSpaceIds, initiallySelectedSpaceIds } = shareOptions; - const activeSpaceId = spaces.find((space) => space.isActiveSpace)!.id; + const activeSpaceId = + !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const options = spaces - .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) + .filter( + // filter out spaces that are not already selected and have the feature disabled in that space + ({ id, isFeatureDisabled }) => !isFeatureDisabled || initiallySelectedSpaceIds.includes(id) + ) + .sort(createSpacesComparator(activeSpaceId)) .map((space) => { const checked = selectedSpaceIds.includes(space.id); + const additionalProps = getAdditionalProps(space, activeSpaceId, checked); return { label: space.name, prepend: , @@ -81,8 +105,7 @@ export const SelectableSpacesControl = (props: Props) => { ['data-space-id']: space.id, ['data-test-subj']: `sts-space-selector-row-${space.id}`, ...(isGlobalControlChecked && { disabled: true }), - ...(space.isPartiallyAuthorized && partiallyAuthorizedSpaceProps(checked)), - ...(space.isActiveSpace && activeSpaceProps), + ...additionalProps, }; }); @@ -112,13 +135,13 @@ export const SelectableSpacesControl = (props: Props) => { + @@ -130,25 +153,28 @@ export const SelectableSpacesControl = (props: Props) => { ); }; const getNoSpacesAvailable = () => { - if (spaces.length < 2) { + if (enableCreateNewSpaceLink && spaces.length < 2) { return ; } return null; }; + // if space-agnostic behavior is not enabled, the active space is not selected or deselected by the user, so we have to artificially pad the count for this label + const selectedCountPad = enableSpaceAgnosticBehavior ? 0 : 1; const selectedCount = - selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + 1; + selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + + selectedCountPad; const hiddenCount = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; const selectSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel', + 'xpack.spaces.shareToSpace.shareModeControl.selectSpacesLabel', { defaultMessage: 'Select spaces' } ); const selectedSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel', + 'xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel', { defaultMessage: '{selectedCount} selected', values: { selectedCount } } ); const hiddenSpacesLabel = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel', + 'xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel', { defaultMessage: '+{hiddenCount} hidden', values: { hiddenCount } } ); const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null; @@ -193,3 +219,55 @@ export const SelectableSpacesControl = (props: Props) => { ); }; + +/** + * Gets additional props for the selection option. + */ +function getAdditionalProps( + space: ShareToSpaceTarget, + activeSpaceId: string | false, + checked: boolean +) { + if (space.id === activeSpaceId) { + return { + append: APPEND_ACTIVE_SPACE, + disabled: true, + checked: 'on' as 'on', + }; + } + if (space.cannotShareToSpace) { + return { + append: ( + <> + {checked ? APPEND_CANNOT_DESELECT : APPEND_CANNOT_SELECT} + {space.isFeatureDisabled ? APPEND_FEATURE_IS_DISABLED : null} + + ), + disabled: true, + }; + } + if (space.isFeatureDisabled) { + return { + append: APPEND_FEATURE_IS_DISABLED, + }; + } +} + +/** + * Given the active space, create a comparator to sort a ShareToSpaceTarget array so that the active space is at the beginning, and space(s) for + * which the current feature is disabled are all at the end. + */ +function createSpacesComparator(activeSpaceId: string | false) { + return (a: ShareToSpaceTarget, b: ShareToSpaceTarget) => { + if (a.id === activeSpaceId) { + return -1; + } + if (b.id === activeSpaceId) { + return 1; + } + if (a.isFeatureDisabled !== b.isFeatureDisabled) { + return a.isFeatureDisabled ? 1 : -1; + } + return 0; + }; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx index 1f71434de577d4..23b2dc02ec3cc2 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -8,27 +8,33 @@ import './share_mode_control.scss'; import React from 'react'; import { + EuiCallOut, EuiCheckableCard, EuiFlexGroup, EuiFlexItem, - EuiFormFieldset, EuiIconTip, + EuiLink, EuiLoadingSpinner, EuiSpacer, EuiText, - EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; -import { SpaceTarget } from '../types'; +import { DocumentationLinksService } from '../../lib'; +import { useSpaces } from '../../spaces_context'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareOptions } from '../types'; interface Props { - spaces: SpaceTarget[]; + spaces: ShareToSpaceTarget[]; + objectNoun: string; canShareToAllSpaces: boolean; - selectedSpaceIds: string[]; + shareOptions: ShareOptions; onChange: (selectedSpaceIds: string[]) => void; - disabled?: boolean; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } function createLabel({ @@ -63,31 +69,41 @@ function createLabel({ } export const ShareModeControl = (props: Props) => { - const { spaces, canShareToAllSpaces, selectedSpaceIds, onChange } = props; + const { + spaces, + objectNoun, + canShareToAllSpaces, + shareOptions, + onChange, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; + const { services } = useSpaces(); + const { docLinks } = services; if (spaces.length === 0) { return ; } + const { selectedSpaceIds } = shareOptions; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const shareToAllSpaces = { id: 'shareToAllSpaces', - title: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title', - { defaultMessage: 'All spaces' } - ), - text: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text', - { defaultMessage: 'Make object available in all current and future spaces.' } - ), + title: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title', { + defaultMessage: 'All spaces', + }), + text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text', { + defaultMessage: 'Make {objectNoun} available in all current and future spaces.', + values: { objectNoun }, + }), ...(!canShareToAllSpaces && { tooltip: isGlobalControlChecked ? i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', { defaultMessage: 'You need additional privileges to change this option.' } ) : i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', { defaultMessage: 'You need additional privileges to use this option.' } ), }), @@ -96,19 +112,15 @@ export const ShareModeControl = (props: Props) => { const shareToExplicitSpaces = { id: 'shareToExplicitSpaces', title: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title', + 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title', { defaultMessage: 'Select spaces' } ), - text: i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text', - { defaultMessage: 'Make object available in selected spaces only.' } - ), + text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', { + defaultMessage: 'Make {objectNoun} available in selected spaces only.', + values: { objectNoun }, + }), disabled: !canShareToAllSpaces && isGlobalControlChecked, }; - const shareOptionsTitle = i18n.translate( - 'xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle', - { defaultMessage: 'Share options' } - ); const toggleShareOption = (allSpaces: boolean) => { const updatedSpaceIds = allSpaces @@ -117,35 +129,77 @@ export const ShareModeControl = (props: Props) => { onChange(updatedSpaceIds); }; + const getPrivilegeWarning = () => { + if (!shareToExplicitSpaces.disabled) { + return null; + } + + const kibanaPrivilegesUrl = new DocumentationLinksService( + docLinks! + ).getKibanaPrivilegesDocUrl(); + + return ( + <> + + } + color="warning" + > + + + + ), + }} + /> + + + + + ); + }; + return ( <> - - {shareOptionsTitle} - - ), - }} + {getPrivilegeWarning()} + + toggleShareOption(false)} + disabled={shareToExplicitSpaces.disabled} > - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > - - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} + - + + + toggleShareOption(true)} + disabled={shareToAllSpaces.disabled} + /> ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx deleted file mode 100644 index 59b8d47e40e024..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx +++ /dev/null @@ -1,489 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import Boom from '@hapi/boom'; -import { mountWithIntl, nextTick } from '@kbn/test/jest'; -import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; -import { ShareToSpaceForm } from './share_to_space_form'; -import { EuiLoadingSpinner, EuiSelectable } from '@elastic/eui'; -import { Space } from '../../../../../../src/plugins/spaces_oss/common'; -import { findTestSubject } from '@kbn/test/jest'; -import { SelectableSpacesControl } from './selectable_spaces_control'; -import { act } from '@testing-library/react'; -import { spacesManagerMock } from '../../spaces_manager/mocks'; -import { SpacesManager } from '../../spaces_manager'; -import { coreMock } from '../../../../../../src/core/public/mocks'; -import { ToastsApi } from 'src/core/public'; -import { EuiCallOut } from '@elastic/eui'; -import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; -import { NoSpacesAvailable } from './no_spaces_available'; -import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; -import { ContextWrapper } from '.'; - -interface SetupOpts { - mockSpaces?: Space[]; - namespaces?: string[]; - returnBeforeSpacesLoad?: boolean; -} - -const setup = async (opts: SetupOpts = {}) => { - const onClose = jest.fn(); - const onObjectUpdated = jest.fn(); - - const mockSpacesManager = spacesManagerMock.create(); - - mockSpacesManager.getActiveSpace.mockResolvedValue({ - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }); - - mockSpacesManager.getSpaces.mockResolvedValue( - opts.mockSpaces || [ - { - id: 'space-1', - name: 'Space 1', - disabledFeatures: [], - }, - { - id: 'space-2', - name: 'Space 2', - disabledFeatures: [], - }, - { - id: 'space-3', - name: 'Space 3', - disabledFeatures: [], - }, - { - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }, - ] - ); - - mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ shareToAllSpaces: true }); - - const mockToastNotifications = { - addError: jest.fn(), - addSuccess: jest.fn(), - }; - const savedObjectToShare = { - type: 'dashboard', - id: 'my-dash', - references: [ - { - type: 'visualization', - id: 'my-viz', - name: 'My Viz', - }, - ], - meta: { icon: 'dashboard', title: 'foo' }, - namespaces: opts.namespaces || ['my-active-space', 'space-1'], - } as SavedObjectsManagementRecord; - - const { getStartServices } = coreMock.createSetup(); - const startServices = coreMock.createStart(); - startServices.application.capabilities = { - ...startServices.application.capabilities, - spaces: { manage: true }, - }; - getStartServices.mockResolvedValue([startServices, , ,]); - - // the flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper - // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change - const wrapper = mountWithIntl( - - - - ); - - // wait for context wrapper to rerender - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - if (!opts.returnBeforeSpacesLoad) { - // Wait for spaces manager to complete and flyout to rerender - await act(async () => { - await nextTick(); - wrapper.update(); - }); - } - - return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; -}; - -describe('ShareToSpaceFlyout', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - it('waits for spaces to load', async () => { - const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - }); - - it('shows a message within a NoSpacesAvailable when no spaces are available', async () => { - const { wrapper, onClose } = await setup({ - mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows a message within a NoSpacesAvailable when only the active space is available', async () => { - const { wrapper, onClose } = await setup({ - mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('does not show a warning callout when the saved object has multiple namespaces', async () => { - const { wrapper, onClose } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiCallOut)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows a warning callout when the saved object only has one namespace', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiCallOut)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('does not show the Copy flyout by default', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { - const { wrapper, onClose } = await setup({ namespaces: ['my-active-space'] }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout - - await act(async () => { - copyButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); - expect(onClose).toHaveBeenCalledTimes(0); - }); - - it('handles errors thrown from shareSavedObjectsAdd API call', async () => { - const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); - - mockSpacesManager.shareSavedObjectAdd.mockImplementation(() => { - return Promise.reject(Boom.serverUnavailable('Something bad happened')); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); - expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); - expect(mockToastNotifications.addError).toHaveBeenCalled(); - }); - - it('handles errors thrown from shareSavedObjectsRemove API call', async () => { - const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); - - mockSpacesManager.shareSavedObjectRemove.mockImplementation(() => { - return Promise.reject(Boom.serverUnavailable('Something bad happened')); - }); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); - expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); - expect(mockToastNotifications.addError).toHaveBeenCalled(); - }); - - it('allows the form to be filled out to add a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); - expect(shareSavedObjectRemove).not.toHaveBeenCalled(); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('allows the form to be filled out to remove a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange([]); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).not.toHaveBeenCalled(); - expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('allows the form to be filled out to add and remove a space', async () => { - const { - wrapper, - onClose, - mockSpacesManager, - mockToastNotifications, - savedObjectToShare, - } = await setup(); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); - expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); - - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); - expect(mockToastNotifications.addError).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - describe('space selection', () => { - const mockSpaces = [ - { - // normal "fully authorized" space selection option -- not the active space - id: 'space-1', - name: 'Space 1', - disabledFeatures: [], - }, - { - // "partially authorized" space selection option -- not the active space - id: 'space-2', - name: 'Space 2', - disabledFeatures: [], - authorizedPurposes: { shareSavedObjectsIntoSpace: false }, - }, - { - // "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top) - id: 'my-active-space', - name: 'my active space', - disabledFeatures: [], - }, - ]; - - const expectActiveSpace = (option: any) => { - expect(option.append).toMatchInlineSnapshot(` - - Current - - `); - // by definition, the active space will always be checked - expect(option.checked).toEqual('on'); - expect(option.disabled).toEqual(true); - }; - const expectInactiveSpace = (option: any, checked: boolean) => { - expect(option.append).toBeUndefined(); - expect(option.checked).toEqual(checked ? 'on' : undefined); - expect(option.disabled).toBeUndefined(); - }; - const expectPartiallyAuthorizedSpace = (option: any, checked: boolean) => { - if (checked) { - expect(option.append).toMatchInlineSnapshot(` - - `); - } else { - expect(option.append).toMatchInlineSnapshot(` - - `); - } - expect(option.checked).toEqual(checked ? 'on' : undefined); - expect(option.disabled).toEqual(true); - }; - - it('correctly defines space selection options when spaces are not selected', async () => { - const namespaces = ['my-active-space']; // the saved object's current namespaces; it will always exist in at least the active namespace - const { wrapper } = await setup({ mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], false); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], false); - }); - - it('correctly defines space selection options when spaces are selected', async () => { - const namespaces = ['my-active-space', 'space-1', 'space-2']; // the saved object's current namespaces - const { wrapper } = await setup({ mockSpaces, namespaces }); - - const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); - const selectOptions = selectable.prop('options'); - expect(selectOptions[0]['data-space-id']).toEqual('my-active-space'); - expectActiveSpace(selectOptions[0]); - expect(selectOptions[1]['data-space-id']).toEqual('space-1'); - expectInactiveSpace(selectOptions[1], true); - expect(selectOptions[2]['data-space-id']).toEqual('space-2'); - expectPartiallyAuthorizedSpace(selectOptions[2], true); - }); - }); -}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx index 69fd89dab58140..0f9783e3ac8c07 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -5,288 +5,12 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; -import { - EuiFlyout, - EuiIcon, - EuiFlyoutHeader, - EuiTitle, - EuiText, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiButton, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ToastsStart } from 'src/core/public'; -import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; -import { GetSpaceResult } from '../../../common'; -import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; -import { SpacesManager } from '../../spaces_manager'; -import { ShareToSpaceForm } from './share_to_space_form'; -import { ShareOptions, SpaceTarget } from '../types'; -import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import React from 'react'; +import type { ShareToSpaceFlyoutProps } from '../../../../../../src/plugins/spaces_oss/public'; +import { ShareToSpaceFlyoutInternal } from './share_to_space_flyout_internal'; -interface Props { - onClose: () => void; - onObjectUpdated: () => void; - savedObject: SavedObjectsManagementRecord; - spacesManager: SpacesManager; - toastNotifications: ToastsStart; -} - -const arraysAreEqual = (a: unknown[], b: unknown[]) => - a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); - -export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; - const { namespaces: currentNamespaces = [] } = savedObject; - const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); - const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); - const [showMakeCopy, setShowMakeCopy] = useState(false); - - const [{ isLoading, spaces }, setSpacesState] = useState<{ - isLoading: boolean; - spaces: SpaceTarget[]; - }>({ isLoading: true, spaces: [] }); - useEffect(() => { - const getSpaces = spacesManager.getSpaces({ includeAuthorizedPurposes: true }); - const getActiveSpace = spacesManager.getActiveSpace(); - const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObject.type); - Promise.all([getSpaces, getActiveSpace, getPermissions]) - .then(([allSpaces, activeSpace, permissions]) => { - setShareOptions({ - selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), - }); - setCanShareToAllSpaces(permissions.shareToAllSpaces); - const createSpaceTarget = (space: GetSpaceResult): SpaceTarget => ({ - ...space, - isActiveSpace: space.id === activeSpace.id, - isPartiallyAuthorized: space.authorizedPurposes?.shareSavedObjectsIntoSpace === false, - }); - setSpacesState({ - isLoading: false, - spaces: allSpaces.map((space) => createSpaceTarget(space)), - }); - }) - .catch((e) => { - toastNotifications.addError(e, { - title: i18n.translate('xpack.spaces.management.shareToSpace.spacesLoadErrorTitle', { - defaultMessage: 'Error loading available spaces', - }), - }); - }); - }, [currentNamespaces, spacesManager, savedObject, toastNotifications]); - - const getSelectionChanges = () => { - const activeSpace = spaces.find((space) => space.isActiveSpace); - if (!activeSpace) { - return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; - } - const initialSelection = currentNamespaces.filter( - (spaceId) => spaceId !== activeSpace.id && spaceId !== UNKNOWN_SPACE - ); - const { selectedSpaceIds } = shareOptions; - const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); - const isSharedToAllSpaces = - !initialSelection.includes(ALL_SPACES_ID) && filteredSelection.includes(ALL_SPACES_ID); - const isUnsharedFromAllSpaces = - initialSelection.includes(ALL_SPACES_ID) && !filteredSelection.includes(ALL_SPACES_ID); - const selectedSpacesChanged = - !filteredSelection.includes(ALL_SPACES_ID) && - !arraysAreEqual(initialSelection, filteredSelection); - const isSelectionChanged = - isSharedToAllSpaces || - isUnsharedFromAllSpaces || - (!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged); - - const selectedSpacesToAdd = filteredSelection.filter( - (spaceId) => !initialSelection.includes(spaceId) - ); - const selectedSpacesToRemove = initialSelection.filter( - (spaceId) => !filteredSelection.includes(spaceId) - ); - - const spacesToAdd = isSharedToAllSpaces - ? [ALL_SPACES_ID] - : isUnsharedFromAllSpaces - ? [activeSpace.id, ...selectedSpacesToAdd] - : selectedSpacesToAdd; - const spacesToRemove = isUnsharedFromAllSpaces - ? [ALL_SPACES_ID] - : isSharedToAllSpaces - ? [activeSpace.id, ...initialSelection] - : selectedSpacesToRemove; - return { isSelectionChanged, spacesToAdd, spacesToRemove }; - }; - const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); - - const [shareInProgress, setShareInProgress] = useState(false); - - async function startShare() { - setShareInProgress(true); - try { - const { type, id, meta } = savedObject; - const title = - currentNamespaces.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareNewSuccessTitle', { - defaultMessage: 'Object is now shared', - }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { - defaultMessage: 'Object was updated', - }); - const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); - if (spacesToAdd.length > 0) { - await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); - const spaceTargets = isSharedToAllSpaces ? 'all' : `${spacesToAdd.length}`; - const text = - !isSharedToAllSpaces && spacesToAdd.length === 1 - ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { - defaultMessage: `'{object}' was added to 1 space.`, - values: { object: meta.title }, - }) - : i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural', { - defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, - values: { object: meta.title, spaceTargets }, - }); - toastNotifications.addSuccess({ title, text }); - } - if (spacesToRemove.length > 0) { - await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); - const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); - const spaceTargets = isUnsharedFromAllSpaces ? 'all' : `${spacesToRemove.length}`; - const text = - !isUnsharedFromAllSpaces && spacesToRemove.length === 1 - ? i18n.translate( - 'xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', - { - defaultMessage: `'{object}' was removed from 1 space.`, - values: { object: meta.title }, - } - ) - : i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural', { - defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, - values: { object: meta.title, spaceTargets }, - }); - if (!isSharedToAllSpaces) { - toastNotifications.addSuccess({ title, text }); - } - } - onObjectUpdated(); - onClose(); - } catch (e) { - setShareInProgress(false); - toastNotifications.addError(e, { - title: i18n.translate('xpack.spaces.management.shareToSpace.shareErrorTitle', { - defaultMessage: 'Error updating saved object', - }), - }); - } - } - - const getFlyoutBody = () => { - // Step 1: loading assets for main form - if (isLoading) { - return ; - } - - const activeSpace = spaces.find((x) => x.isActiveSpace)!; - const showShareWarning = - spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]); - // Step 2: Share has not been initiated yet; User must fill out form to continue. - return ( - setShowMakeCopy(true)} - /> - ); +export const getShareToSpaceFlyoutComponent = (): React.FC => { + return (props: ShareToSpaceFlyoutProps) => { + return ; }; - - if (showMakeCopy) { - return ( - - ); - } - - return ( - - - - - - - - -

- -

-
-
-
-
- - - - - - - -

{savedObject.meta.title}

-
-
-
- - - - {getFlyoutBody()} -
- - - - - onClose()} - data-test-subj="sts-cancel-button" - disabled={shareInProgress} - > - - - - - startShare()} - data-test-subj="sts-initiate-button" - disabled={!isSelectionChanged || shareInProgress} - > - - - - - -
- ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx new file mode 100644 index 00000000000000..1b33b42637fe8b --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -0,0 +1,741 @@ +/* + * 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 Boom from '@hapi/boom'; +import { mountWithIntl, nextTick, findTestSubject } from '@kbn/test/jest'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { + EuiCallOut, + EuiCheckableCard, + EuiCheckableCardProps, + EuiIconTip, + EuiLoadingSpinner, + EuiSelectable, +} from '@elastic/eui'; +import { Space } from '../../../../../../src/plugins/spaces_oss/common'; +import { SelectableSpacesControl } from './selectable_spaces_control'; +import { act } from '@testing-library/react'; +import { spacesManagerMock } from '../../spaces_manager/mocks'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { NoSpacesAvailable } from './no_spaces_available'; +import { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; +import { ShareModeControl } from './share_mode_control'; +import { ReactWrapper } from 'enzyme'; +import { ALL_SPACES_ID } from '../../../common/constants'; +import { getSpacesContextWrapper } from '../../spaces_context'; + +interface SetupOpts { + mockSpaces?: Space[]; + namespaces?: string[]; + returnBeforeSpacesLoad?: boolean; + canShareToAllSpaces?: boolean; // default: true + enableCreateCopyCallout?: boolean; + enableCreateNewSpaceLink?: boolean; + behaviorContext?: 'within-space' | 'outside-space'; + mockFeatureId?: string; // optional feature ID to use for the SpacesContext +} + +const setup = async (opts: SetupOpts = {}) => { + const onClose = jest.fn(); + const onUpdate = jest.fn(); + + const mockSpacesManager = spacesManagerMock.create(); + + // note: this call is made in the SpacesContext + mockSpacesManager.getActiveSpace.mockResolvedValue({ + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }); + + // note: this call is made in the SpacesContext + mockSpacesManager.getSpaces.mockResolvedValue( + opts.mockSpaces || [ + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ] + ); + + mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ + shareToAllSpaces: opts.canShareToAllSpaces ?? true, + }); + + const savedObjectToShare = { + type: 'dashboard', + id: 'my-dash', + namespaces: opts.namespaces || ['my-active-space', 'space-1'], + icon: 'dashboard', + title: 'foo', + }; + + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + startServices.application.capabilities = { + ...startServices.application.capabilities, + spaces: { manage: true }, + }; + const mockToastNotifications = startServices.notifications.toasts; + getStartServices.mockResolvedValue([startServices, , ,]); + + const SpacesContext = getSpacesContextWrapper({ + getStartServices, + spacesManager: mockSpacesManager, + }); + const ShareToSpaceFlyout = getShareToSpaceFlyoutComponent(); + // the internal flyout depends upon the Kibana React Context, and it cannot be used without the context wrapper + // the context wrapper is only split into a separate component to avoid recreating the context upon every flyout state change + // the ShareToSpaceFlyout component renders the internal flyout inside of the context wrapper + const wrapper = mountWithIntl( + + + + ); + + // wait for context wrapper to rerender + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + if (!opts.returnBeforeSpacesLoad) { + // Wait for spaces manager to complete and flyout to rerender + wrapper.update(); + } + + return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; +}; + +describe('ShareToSpaceFlyout', () => { + it('waits for spaces to load', async () => { + const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + wrapper.update(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + }); + + describe('without enableCreateCopyCallout', () => { + it('does not show a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('with enableCreateCopyCallout', () => { + const enableCreateCopyCallout = true; + + it('does not show a warning callout when the saved object has multiple namespaces', async () => { + const { wrapper, onClose } = await setup({ enableCreateCopyCallout }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows a warning callout when the saved object only has one namespace', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not show the Copy flyout by default', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(0); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('shows the Copy flyout if the the "Make a copy" button is clicked', async () => { + const { wrapper, onClose } = await setup({ + enableCreateCopyCallout, + namespaces: ['my-active-space'], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout + + await act(async () => { + copyButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(CopySavedObjectsToSpaceFlyout)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('without enableCreateNewSpaceLink', () => { + it('does not render a NoSpacesAvailable component when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('does not render a NoSpacesAvailable component when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + describe('with enableCreateNewSpaceLink', () => { + const enableCreateNewSpaceLink = true; + + it('renders a NoSpacesAvailable component when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + enableCreateNewSpaceLink, + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('renders a NoSpacesAvailable component when only the active space is available', async () => { + const { wrapper, onClose } = await setup({ + enableCreateNewSpaceLink, + mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], + }); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); + expect(onClose).toHaveBeenCalledTimes(0); + }); + }); + + it('handles errors thrown from shareSavedObjectsAdd API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectAdd.mockRejectedValue( + Boom.serverUnavailable('Something bad happened') + ); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('handles errors thrown from shareSavedObjectsRemove API call', async () => { + const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); + + mockSpacesManager.shareSavedObjectRemove.mockRejectedValue( + Boom.serverUnavailable('Something bad happened') + ); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); + expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); + expect(mockToastNotifications.addError).toHaveBeenCalled(); + }); + + it('allows the form to be filled out to add a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).not.toHaveBeenCalled(); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange([]); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).not.toHaveBeenCalled(); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('allows the form to be filled out to add and remove a space', async () => { + const { + wrapper, + onClose, + mockSpacesManager, + mockToastNotifications, + savedObjectToShare, + } = await setup(); + + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); + + // Using props callback instead of simulating clicks, + // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + + act(() => { + spaceSelector.props().onChange(['space-2', 'space-3']); + }); + + const startButton = findTestSubject(wrapper, 'sts-initiate-button'); + + await act(async () => { + startButton.simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { type, id } = savedObjectToShare; + const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; + expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); + expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); + expect(mockToastNotifications.addError).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + describe('correctly renders checkable cards', () => { + function getCheckableCardProps( + wrapper: ReactWrapper> + ) { + const iconTip = wrapper.find(EuiIconTip); + return { + checked: wrapper.prop('checked'), + disabled: wrapper.prop('disabled'), + ...(iconTip.length > 0 && { tooltip: iconTip.prop('content') as string }), + }; + } + function getCheckableCards(wrapper: ReactWrapper) { + return { + explicitSpacesCard: getCheckableCardProps( + wrapper.find('#shareToExplicitSpaces').find(EuiCheckableCard) + ), + allSpacesCard: getCheckableCardProps( + wrapper.find('#shareToAllSpaces').find(EuiCheckableCard) + ), + }; + } + + describe('when user has privileges to share to all spaces', () => { + const canShareToAllSpaces = true; + + it('and the object is not shared to all spaces', async () => { + const namespaces = ['my-active-space']; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: true, disabled: false }, + allSpacesCard: { checked: false, disabled: false }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + + it('and the object is shared to all spaces', async () => { + const namespaces = [ALL_SPACES_ID]; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: false, disabled: false }, + allSpacesCard: { checked: true, disabled: false }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + }); + + describe('when user does not have privileges to share to all spaces', () => { + const canShareToAllSpaces = false; + + it('and the object is not shared to all spaces', async () => { + const namespaces = ['my-active-space']; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: true, disabled: false }, + allSpacesCard: { + checked: false, + disabled: true, + tooltip: 'You need additional privileges to use this option.', + }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + }); + + it('and the object is shared to all spaces', async () => { + const namespaces = [ALL_SPACES_ID]; + const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); + const shareModeControl = wrapper.find(ShareModeControl); + const checkableCards = getCheckableCards(shareModeControl); + + expect(checkableCards).toEqual({ + explicitSpacesCard: { checked: false, disabled: true }, + allSpacesCard: { + checked: true, + disabled: true, + tooltip: 'You need additional privileges to change this option.', + }, + }); + expect(shareModeControl.find(EuiCallOut)).toHaveLength(1); // "Additional privileges required" callout + }); + }); + }); + + describe('space selection', () => { + const mockFeatureId = 'some-feature'; + + const mockSpaces = [ + { + // normal "fully authorized" space selection option -- not the active space + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + // normal "fully authorized" space selection option, with a disabled feature -- not the active space + id: 'space-2', + name: 'Space 2', + disabledFeatures: [mockFeatureId], + }, + { + // "partially authorized" space selection option -- not the active space + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + authorizedPurposes: { shareSavedObjectsIntoSpace: false }, + }, + { + // "partially authorized" space selection option, with a disabled feature -- not the active space + id: 'space-4', + name: 'Space 4', + disabledFeatures: [mockFeatureId], + authorizedPurposes: { shareSavedObjectsIntoSpace: false }, + }, + { + // "active space" selection option (determined by an ID that matches the result of `getActiveSpace`, mocked at top) + id: 'my-active-space', + name: 'my active space', + disabledFeatures: [], + }, + ]; + + const expectActiveSpace = (option: any, { spaceId }: { spaceId: string }) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toMatchInlineSnapshot(` + + Current + + `); + // by definition, the active space will always be checked + expect(option.checked).toEqual('on'); + expect(option.disabled).toEqual(true); + }; + const expectNeedAdditionalPrivileges = ( + option: any, + { + spaceId, + checked, + featureIsDisabled, + }: { spaceId: string; checked: boolean; featureIsDisabled?: boolean } + ) => { + expect(option['data-space-id']).toEqual(spaceId); + if (checked && featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + + `); + } else if (checked && !featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + `); + } else if (!checked && !featureIsDisabled) { + expect(option.append).toMatchInlineSnapshot(` + + + + `); + } else { + throw new Error('Unexpected test case!'); + } + expect(option.checked).toEqual(checked ? 'on' : undefined); + expect(option.disabled).toEqual(true); + }; + const expectFeatureIsDisabled = (option: any, { spaceId }: { spaceId: string }) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toMatchInlineSnapshot(` + + `); + expect(option.checked).toEqual('on'); + expect(option.disabled).toBeUndefined(); + }; + const expectInactiveSpace = ( + option: any, + { spaceId, checked }: { spaceId: string; checked: boolean } + ) => { + expect(option['data-space-id']).toEqual(spaceId); + expect(option.append).toBeUndefined(); + expect(option.checked).toEqual(checked ? 'on' : undefined); + expect(option.disabled).toBeUndefined(); + }; + + describe('with behaviorContext="within-space" (default)', () => { + it('correctly defines space selection options', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: true }); + expectInactiveSpace(options[2], { spaceId: 'space-2', checked: false }); + expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-3', checked: true }); + expectNeedAdditionalPrivileges(options[4], { spaceId: 'space-4', checked: false }); + }); + + describe('with a SpacesContext for a specific feature', () => { + it('correctly defines space selection options when affected spaces are not selected', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces, mockFeatureId }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(3); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: false }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: false }); + // space-2 and space-4 are omitted, because they are not selected and the current feature is disabled in those spaces + }); + + it('correctly defines space selection options when affected spaces are already selected', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-2', 'space-3', 'space-4']; // the saved object's current namespaces + const { wrapper } = await setup({ mockSpaces, namespaces, mockFeatureId }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectActiveSpace(options[0], { spaceId: 'my-active-space' }); + expectInactiveSpace(options[1], { spaceId: 'space-1', checked: true }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: true }); + // space-2 and space-4 are at the end, because they are selected and the current feature is disabled in those spaces + expectFeatureIsDisabled(options[3], { spaceId: 'space-2' }); + expectNeedAdditionalPrivileges(options[4], { + spaceId: 'space-4', + checked: true, + featureIsDisabled: true, + }); + }); + }); + }); + + describe('with behaviorContext="outside-space"', () => { + const behaviorContext = 'outside-space'; + + it('correctly defines space selection options', async () => { + const namespaces = ['my-active-space', 'space-1', 'space-3']; // the saved object's current namespaces + const { wrapper } = await setup({ behaviorContext, mockSpaces, namespaces }); + + const selectable = wrapper.find(SelectableSpacesControl).find(EuiSelectable); + const options = selectable.prop('options'); + expect(options).toHaveLength(5); + expectInactiveSpace(options[0], { spaceId: 'space-1', checked: true }); + expectInactiveSpace(options[1], { spaceId: 'space-2', checked: false }); + expectNeedAdditionalPrivileges(options[2], { spaceId: 'space-3', checked: true }); + expectNeedAdditionalPrivileges(options[3], { spaceId: 'space-4', checked: false }); + expectInactiveSpace(options[4], { spaceId: 'my-active-space', checked: true }); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx new file mode 100644 index 00000000000000..8d9875977af18f --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -0,0 +1,352 @@ +/* + * 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, useEffect, useMemo } from 'react'; +import { + EuiFlyout, + EuiIcon, + EuiFlyoutHeader, + EuiTitle, + EuiText, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ToastsStart } from 'src/core/public'; +import type { + ShareToSpaceFlyoutProps, + ShareToSpaceSavedObjectTarget, +} from 'src/plugins/spaces_oss/public'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; +import { SpacesManager } from '../../spaces_manager'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareToSpaceForm } from './share_to_space_form'; +import { ShareOptions } from '../types'; +import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { useSpaces } from '../../spaces_context'; +import { DEFAULT_OBJECT_NOUN } from './constants'; + +const ALL_SPACES_TARGET = i18n.translate('xpack.spaces.shareToSpace.allSpacesTarget', { + defaultMessage: 'all', +}); + +const arraysAreEqual = (a: unknown[], b: unknown[]) => + a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); + +function createDefaultChangeSpacesHandler( + object: Required>, + spacesManager: SpacesManager, + toastNotifications: ToastsStart +) { + return async (spacesToAdd: string[], spacesToRemove: string[]) => { + const { type, id, title } = object; + const toastTitle = i18n.translate('xpack.spaces.shareToSpace.shareSuccessTitle', { + values: { objectNoun: object.noun }, + defaultMessage: 'Updated {objectNoun}', + }); + const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); + if (spacesToAdd.length > 0) { + await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); + const spaceTargets = isSharedToAllSpaces ? ALL_SPACES_TARGET : `${spacesToAdd.length}`; + const toastText = + !isSharedToAllSpaces && spacesToAdd.length === 1 + ? i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextSingular', { + defaultMessage: `'{object}' was added to 1 space.`, + values: { object: title }, + }) + : i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextPlural', { + defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, + values: { object: title, spaceTargets }, + }); + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); + } + if (spacesToRemove.length > 0) { + await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); + const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); + const spaceTargets = isUnsharedFromAllSpaces ? ALL_SPACES_TARGET : `${spacesToRemove.length}`; + const toastText = + !isUnsharedFromAllSpaces && spacesToRemove.length === 1 + ? i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextSingular', { + defaultMessage: `'{object}' was removed from 1 space.`, + values: { object: title }, + }) + : i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextPlural', { + defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, + values: { object: title, spaceTargets }, + }); + if (!isSharedToAllSpaces) { + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); + } + } + }; +} + +export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { + const { spacesManager, shareToSpacesDataPromise, services } = useSpaces(); + const { notifications } = services; + const toastNotifications = notifications!.toasts; + + const { savedObjectTarget: object } = props; + const savedObjectTarget = useMemo( + () => ({ + type: object.type, + id: object.id, + namespaces: object.namespaces, + icon: object.icon, + title: object.title || `${object.type} [id=${object.id}]`, + noun: object.noun || DEFAULT_OBJECT_NOUN, + }), + [object] + ); + const { + flyoutIcon, + flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', { + defaultMessage: 'Edit spaces for {objectNoun}', + values: { objectNoun: savedObjectTarget.noun }, + }), + enableCreateCopyCallout = false, + enableCreateNewSpaceLink = false, + behaviorContext, + changeSpacesHandler = createDefaultChangeSpacesHandler( + savedObjectTarget, + spacesManager, + toastNotifications + ), + onUpdate = () => null, + onClose = () => null, + } = props; + const enableSpaceAgnosticBehavior = behaviorContext === 'outside-space'; + + const [shareOptions, setShareOptions] = useState({ + selectedSpaceIds: [], + initiallySelectedSpaceIds: [], + }); + const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); + const [showMakeCopy, setShowMakeCopy] = useState(false); + + const [{ isLoading, spaces }, setSpacesState] = useState<{ + isLoading: boolean; + spaces: ShareToSpaceTarget[]; + }>({ isLoading: true, spaces: [] }); + useEffect(() => { + const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); + Promise.all([shareToSpacesDataPromise, getPermissions]) + .then(([shareToSpacesData, permissions]) => { + const activeSpaceId = !enableSpaceAgnosticBehavior && shareToSpacesData.activeSpaceId; + const selectedSpaceIds = savedObjectTarget.namespaces.filter( + (spaceId) => spaceId !== activeSpaceId + ); + setShareOptions({ + selectedSpaceIds, + initiallySelectedSpaceIds: selectedSpaceIds, + }); + setCanShareToAllSpaces(permissions.shareToAllSpaces); + setSpacesState({ + isLoading: false, + spaces: [...shareToSpacesData.spacesMap].map(([, spaceTarget]) => spaceTarget), + }); + }) + .catch((e) => { + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.shareToSpace.spacesLoadErrorTitle', { + defaultMessage: 'Error loading available spaces', + }), + }); + }); + }, [ + savedObjectTarget, + spacesManager, + shareToSpacesDataPromise, + toastNotifications, + enableSpaceAgnosticBehavior, + ]); + + const getSelectionChanges = () => { + if (!spaces.length) { + return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; + } + const activeSpaceId = + !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; + const initialSelection = savedObjectTarget.namespaces.filter( + (spaceId) => spaceId !== activeSpaceId && spaceId !== UNKNOWN_SPACE + ); + const { selectedSpaceIds } = shareOptions; + const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); + + const initiallySharedToAllSpaces = initialSelection.includes(ALL_SPACES_ID); + const selectionIncludesAllSpaces = filteredSelection.includes(ALL_SPACES_ID); + + const isSharedToAllSpaces = !initiallySharedToAllSpaces && selectionIncludesAllSpaces; + const isUnsharedFromAllSpaces = initiallySharedToAllSpaces && !selectionIncludesAllSpaces; + + const selectedSpacesChanged = + !selectionIncludesAllSpaces && !arraysAreEqual(initialSelection, filteredSelection); + const isSelectionChanged = + isSharedToAllSpaces || + isUnsharedFromAllSpaces || + (!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged); + + const selectedSpacesToAdd = filteredSelection.filter( + (spaceId) => !initialSelection.includes(spaceId) + ); + const selectedSpacesToRemove = initialSelection.filter( + (spaceId) => !filteredSelection.includes(spaceId) + ); + + const activeSpaceArray = activeSpaceId ? [activeSpaceId] : []; // if we have an active space, it is automatically selected + const spacesToAdd = isSharedToAllSpaces + ? [ALL_SPACES_ID] + : isUnsharedFromAllSpaces + ? [...activeSpaceArray, ...selectedSpacesToAdd] + : selectedSpacesToAdd; + const spacesToRemove = + isUnsharedFromAllSpaces || !isSharedToAllSpaces + ? selectedSpacesToRemove + : [...activeSpaceArray, ...initialSelection]; + return { isSelectionChanged, spacesToAdd, spacesToRemove }; + }; + const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + + const [shareInProgress, setShareInProgress] = useState(false); + + async function startShare() { + setShareInProgress(true); + try { + await changeSpacesHandler(spacesToAdd, spacesToRemove); + onUpdate(); + onClose(); + } catch (e) { + setShareInProgress(false); + toastNotifications.addError(e, { + title: i18n.translate('xpack.spaces.shareToSpace.shareErrorTitle', { + values: { objectNoun: savedObjectTarget.noun }, + defaultMessage: 'Error updating {objectNoun}', + }), + }); + } + } + + const getFlyoutBody = () => { + // Step 1: loading assets for main form + if (isLoading) { + return ; + } + + // If the object has not been shared yet (e.g., it currently exists in exactly one space), and there is at least one space that we could + // share this object to, we want to display a callout to the user that explains the ramifications of shared objects. They might actually + // want to make a copy instead, so this callout contains a link that opens the Copy flyout. + const showCreateCopyCallout = + enableCreateCopyCallout && + spaces.length > 1 && + savedObjectTarget.namespaces.length === 1 && + !arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]); + // Step 2: Share has not been initiated yet; User must fill out form to continue. + return ( + setShowMakeCopy(true)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} + /> + ); + }; + + if (showMakeCopy) { + return ( + + ); + } + + const isStartShareButtonDisabled = + !isSelectionChanged || + shareInProgress || + (enableSpaceAgnosticBehavior && !shareOptions.selectedSpaceIds.length); // the object must exist in at least one space, or all spaces + + return ( + + + + {flyoutIcon && ( + + + + )} + + +

{flyoutTitle}

+
+
+
+
+ + + {savedObjectTarget.icon && ( + + + + )} + + +

{savedObjectTarget.title}

+
+
+
+ + + + {getFlyoutBody()} +
+ + + + + onClose()} + data-test-subj="sts-cancel-button" + disabled={shareInProgress} + > + + + + + startShare()} + data-test-subj="sts-initiate-button" + disabled={isStartShareButtonDisabled} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx index ef5b731375f495..49c581b07004b8 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx @@ -7,73 +7,84 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; -import { EuiHorizontalRule, EuiCallOut, EuiLink } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ShareOptions, SpaceTarget } from '../types'; +import { ShareToSpaceTarget } from '../../types'; +import { ShareOptions } from '../types'; import { ShareModeControl } from './share_mode_control'; interface Props { - spaces: SpaceTarget[]; + spaces: ShareToSpaceTarget[]; + objectNoun: string; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; - showShareWarning: boolean; + showCreateCopyCallout: boolean; canShareToAllSpaces: boolean; makeCopy: () => void; + enableCreateNewSpaceLink: boolean; + enableSpaceAgnosticBehavior: boolean; } export const ShareToSpaceForm = (props: Props) => { - const { spaces, onUpdate, shareOptions, showShareWarning, canShareToAllSpaces, makeCopy } = props; + const { + spaces, + objectNoun, + onUpdate, + shareOptions, + showCreateCopyCallout, + canShareToAllSpaces, + makeCopy, + enableCreateNewSpaceLink, + enableSpaceAgnosticBehavior, + } = props; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => onUpdate({ ...shareOptions, selectedSpaceIds }); - const getShareWarning = () => { - if (!showShareWarning) { - return null; - } - - return ( - - - } - color="warning" - > + const createCopyCallout = showCreateCopyCallout ? ( + + makeCopy()}> - - - ), - }} + id="xpack.spaces.shareToSpace.shareWarningTitle" + defaultMessage="Changes are synchronized across spaces" /> - + } + color="warning" + > + makeCopy()}> + + + ), + }} + /> + - - - ); - }; + +
+ ) : null; return (
- {getShareWarning()} + {createCopyCallout} setSelectedSpaceIds(selection)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} />
); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts index 5f8d0dfc2e949c..beed0fd9d592ae 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/index.ts @@ -6,3 +6,5 @@ */ export { ShareSavedObjectsToSpaceService } from './share_saved_objects_to_space_service'; +export { getShareToSpaceFlyoutComponent, getLegacyUrlConflict } from './components'; +export { createRedirectLegacyUrl } from './utils'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx index abe1579f2058fc..a8d503d306ee81 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.test.tsx @@ -5,21 +5,14 @@ * 2.0. */ -import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; import { SavedObjectsManagementRecord } from '../../../../../src/plugins/saved_objects_management/public'; -import { spacesManagerMock } from '../spaces_manager/mocks'; +import { uiApiMock } from '../ui_api/mocks'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; describe('ShareToSpaceSavedObjectsManagementAction', () => { const createAction = () => { - const spacesManager = spacesManagerMock.create(); - const notificationsStart = notificationServiceMock.createStartContract(); - const { getStartServices } = coreMock.createSetup(); - return new ShareToSpaceSavedObjectsManagementAction( - spacesManager, - notificationsStart, - getStartServices - ); + const spacesApiUi = uiApiMock.create(); + return new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); }; describe('#euiAction.available', () => { describe('with an object type that has a namespaceType of "multiple"', () => { diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx index f115119275abd6..feb073745c6162 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx @@ -7,23 +7,20 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { NotificationsStart, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementAction, SavedObjectsManagementRecord, } from '../../../../../src/plugins/saved_objects_management/public'; -import { ContextWrapper, ShareSavedObjectsToSpaceFlyout } from './components'; -import { SpacesManager } from '../spaces_manager'; -import { PluginsStart } from '../plugin'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { public id: string = 'share_saved_objects_to_space'; public euiAction = { - name: i18n.translate('xpack.spaces.management.shareToSpace.actionTitle', { + name: i18n.translate('xpack.spaces.shareToSpace.actionTitle', { defaultMessage: 'Share to space', }), - description: i18n.translate('xpack.spaces.management.shareToSpace.actionDescription', { + description: i18n.translate('xpack.spaces.shareToSpace.actionDescription', { defaultMessage: 'Share this saved object to one or more spaces', }), icon: 'share', @@ -43,11 +40,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage private isDataChanged: boolean = false; - constructor( - private readonly spacesManager: SpacesManager, - private readonly notifications: NotificationsStart, - private readonly getStartServices: StartServicesAccessor - ) { + constructor(private readonly spacesApiUi: SpacesApiUi) { super(); } @@ -56,16 +49,24 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage throw new Error('No record available! `render()` was likely called before `start()`.'); } + const savedObjectTarget = { + type: this.record.type, + id: this.record.id, + namespaces: this.record.namespaces ?? [], + title: this.record.meta.title, + icon: this.record.meta.icon, + }; + const { ShareToSpaceFlyout } = this.spacesApiUi.components; + return ( - - (this.isDataChanged = true)} - savedObject={this.record} - spacesManager={this.spacesManager} - toastNotifications={this.notifications.toasts} - /> - + (this.isDataChanged = true)} + onClose={this.onClose} + enableCreateCopyCallout={true} + enableCreateNewSpaceLink={true} + /> ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx deleted file mode 100644 index d0949da27c5798..00000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx +++ /dev/null @@ -1,207 +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 { shallowWithIntl } from '@kbn/test/jest'; -import { SpacesManager } from '../spaces_manager'; -import { spacesManagerMock } from '../spaces_manager/mocks'; -import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { SpaceTarget } from './types'; - -const ACTIVE_SPACE: SpaceTarget = { - id: 'default', - name: 'Default', - color: '#ffffff', - isActiveSpace: true, -}; -const getSpaceData = (inactiveSpaceCount: number = 0) => { - const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] - .map((name) => ({ - id: name.toLowerCase(), - name, - color: `#123456`, // must be a valid color as `render()` is used below - isActiveSpace: false, - })) - .slice(0, inactiveSpaceCount); - const spaceTargets = [ACTIVE_SPACE, ...inactive]; - const namespaces = spaceTargets.map(({ id }) => id); - return { spaceTargets, namespaces }; -}; - -describe('ShareToSpaceSavedObjectsManagementColumn', () => { - let spacesManager: SpacesManager; - beforeEach(() => { - spacesManager = spacesManagerMock.create(); - }); - - const createColumn = (spaceTargets: SpaceTarget[], namespaces: string[]) => { - const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); - column.data = spaceTargets.reduce( - (acc, cur) => acc.set(cur.id, cur), - new Map() - ); - const element = column.euiColumn.render(namespaces); - return shallowWithIntl(element); - }; - - /** - * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is - * omitted from this list. If more than five named spaces would be displayed, the extras (along with the unauthorized spaces indicator, if - * present) are hidden behind a button. - * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. - */ - describe('#euiColumn.render', () => { - describe('with only the active space', () => { - const { spaceTargets, namespaces } = getSpaceData(); - const wrapper = createColumn(spaceTargets, namespaces); - - it('does not show badges or button', async () => { - const badges = wrapper.find('EuiBadge'); - expect(badges).toHaveLength(0); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and one inactive space', () => { - const { spaceTargets, namespaces } = getSpaceData(1); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows one badge without button', async () => { - const badges = wrapper.find('EuiBadge'); - expect(badges).toMatchInlineSnapshot(` - - Alpha - - `); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and five inactive spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space, five inactive spaces, and one unauthorized space', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, [...namespaces, '?']); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+1']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(5); - const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); - - it('shows badges without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+2']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with the active space and six inactive spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, namespaces); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 1 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot']); - }); - }); - - describe('with the active space, six inactive spaces, and one unauthorized space', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, [...namespaces, '?']); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 2 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+1']); - }); - }); - - describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); - - it('shows badges with button', async () => { - let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button.find('FormattedMessage').props()).toEqual({ - defaultMessage: '+{count} more', - id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', - values: { count: 3 }, - }); - - button.simulate('click'); - badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+2']); - }); - }); - - describe('with only "all spaces"', () => { - const wrapper = createColumn([], ['*']); - - it('shows one badge without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['* All spaces']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - - describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { - // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else - const { spaceTargets, namespaces } = getSpaceData(6); - const wrapper = createColumn(spaceTargets, ['*', ...namespaces, '?']); - - it('shows one badge without button', async () => { - const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); - expect(badgeText).toEqual(['* All spaces']); - const button = wrapper.find('EuiButtonEmpty'); - expect(button).toHaveLength(0); - }); - }); - }); -}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx index 6195095156258c..05e0976da0710c 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -5,151 +5,30 @@ * 2.0. */ -import React, { useState, ReactNode } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBadge } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { EuiToolTip } from '@elastic/eui'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { SavedObjectsManagementColumn } from '../../../../../src/plugins/saved_objects_management/public'; -import { SpaceTarget } from './types'; -import { SpacesManager } from '../spaces_manager'; -import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; -import { getSpaceColor } from '..'; - -const SPACES_DISPLAY_COUNT = 5; - -type SpaceMap = Map; -interface ColumnDataProps { - namespaces?: string[]; - data?: SpaceMap; -} - -const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { - const [isExpanded, setIsExpanded] = useState(false); - - if (!data) { - return null; - } - - const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); - const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) - .length; - let displayedSpaces: SpaceTarget[]; - let button: ReactNode = null; - - if (isSharedToAllSpaces) { - displayedSpaces = [ - { - id: ALL_SPACES_ID, - name: i18n.translate('xpack.spaces.management.shareToSpace.allSpacesLabel', { - defaultMessage: `* All spaces`, - }), - isActiveSpace: false, - color: '#D3DAE6', - }, - ]; - } else { - const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; - const authorizedSpaceTargets: SpaceTarget[] = []; - authorized.forEach((namespace) => { - const spaceTarget = data.get(namespace); - if (spaceTarget === undefined) { - // in the event that a new space was created after this page has loaded, fall back to displaying the space ID - authorizedSpaceTargets.push({ id: namespace, name: namespace, isActiveSpace: false }); - } else if (!spaceTarget.isActiveSpace) { - authorizedSpaceTargets.push(spaceTarget); - } - }); - displayedSpaces = isExpanded - ? authorizedSpaceTargets - : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); - - if (authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT) { - button = isExpanded ? ( - setIsExpanded(false)}> - - - ) : ( - setIsExpanded(true)}> - - - ); - } - } - - const unauthorizedCountBadge = - !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? ( - - - } - > - +{unauthorizedCount} - - - ) : null; - - return ( - - {displayedSpaces.map(({ id, name, color }) => ( - - {name} - - ))} - {unauthorizedCountBadge} - {button} - - ); -}; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; export class ShareToSpaceSavedObjectsManagementColumn - implements SavedObjectsManagementColumn { + implements SavedObjectsManagementColumn { public id: string = 'share_saved_objects_to_space'; - public data: Map | undefined; public euiColumn = { field: 'namespaces', - name: i18n.translate('xpack.spaces.management.shareToSpace.columnTitle', { + name: i18n.translate('xpack.spaces.shareToSpace.columnTitle', { defaultMessage: 'Shared spaces', }), - description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { + description: i18n.translate('xpack.spaces.shareToSpace.columnDescription', { defaultMessage: 'The other spaces that this object is currently shared to', }), - render: (namespaces: string[] | undefined) => ( - - ), - }; - - constructor(private readonly spacesManager: SpacesManager) {} - - public loadData = () => { - this.data = undefined; - return Promise.all([this.spacesManager.getSpaces(), this.spacesManager.getActiveSpace()]).then( - ([spaces, activeSpace]) => { - this.data = spaces - .map((space) => ({ - ...space, - isActiveSpace: space.id === activeSpace.id, - color: getSpaceColor(space), - })) - .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); - return this.data; + render: (namespaces: string[] | undefined) => { + if (!namespaces) { + return null; } - ); + return ; + }, }; + + constructor(private readonly spacesApiUi: SpacesApiUi) {} } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts index eeadb157b51873..6e74fa31ec4b8b 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts @@ -7,19 +7,16 @@ import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareSavedObjectsToSpaceService } from '.'; -import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; +import { uiApiMock } from '../ui_api/mocks'; describe('ShareSavedObjectsToSpaceService', () => { describe('#setup', () => { it('registers the ShareToSpaceSavedObjectsManagement Action and Column', () => { const deps = { - spacesManager: spacesManagerMock.create(), - notificationsSetup: notificationServiceMock.createSetupContract(), savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + spacesApiUi: uiApiMock.create(), }; const service = new ShareSavedObjectsToSpaceService(); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts index 08a7db106d6bb9..86b9c07bebe924 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts @@ -5,35 +5,22 @@ * 2.0. */ -import { NotificationsSetup, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; -import { SpacesManager } from '../spaces_manager'; -import { PluginsStart } from '../plugin'; interface SetupDeps { - spacesManager: SpacesManager; savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; - notificationsSetup: NotificationsSetup; - getStartServices: StartServicesAccessor; + spacesApiUi: SpacesApiUi; } export class ShareSavedObjectsToSpaceService { - public setup({ - spacesManager, - savedObjectsManagementSetup, - notificationsSetup, - getStartServices, - }: SetupDeps) { - const action = new ShareToSpaceSavedObjectsManagementAction( - spacesManager, - notificationsSetup, - getStartServices - ); + public setup({ savedObjectsManagementSetup, spacesApiUi }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction(spacesApiUi); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. - // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesApiUi); // savedObjectsManagementSetup.columns.register(column); } } diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts index f5e0d09a99e4bb..fda561d8c4af11 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts @@ -6,10 +6,10 @@ */ import { SavedObjectsImportRetry, SavedObjectsImportResponse } from 'src/core/public'; -import { GetSpaceResult } from '..'; export interface ShareOptions { selectedSpaceIds: string[]; + initiallySelectedSpaceIds: string[]; } export type ImportRetry = Omit; @@ -17,8 +17,3 @@ export type ImportRetry = Omit; export interface ShareSavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } - -export interface SpaceTarget extends Omit { - isActiveSpace: boolean; - isPartiallyAuthorized?: boolean; -} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/index.ts new file mode 100644 index 00000000000000..a40bc87cd4dc30 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/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 { createRedirectLegacyUrl } from './redirect_legacy_url'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts new file mode 100644 index 00000000000000..84d2958092a650 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { createRedirectLegacyUrl } from './redirect_legacy_url'; + +const APP_ID = 'testAppId'; + +describe('#redirectLegacyUrl', () => { + const setup = () => { + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + const subject = new BehaviorSubject(`not-${APP_ID}`); + subject.next(APP_ID); // test below asserts that the consumer received the most recent APP_ID + startServices.application.currentAppId$ = subject; + const toasts = startServices.notifications.toasts; + const application = startServices.application; + getStartServices.mockResolvedValue([startServices, , ,]); + + const redirectLegacyUrl = createRedirectLegacyUrl(getStartServices); + + return { redirectLegacyUrl, toasts, application }; + }; + + it('creates a toast and redirects to the given path in the current app', async () => { + const { redirectLegacyUrl, toasts, application } = setup(); + + const path = '/foo?bar#baz'; + await redirectLegacyUrl(path); + + expect(toasts.addInfo).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + expect(application.navigateToApp).toHaveBeenCalledWith(APP_ID, { replace: true, path }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts new file mode 100644 index 00000000000000..694465e34049c1 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/utils/redirect_legacy_url.ts @@ -0,0 +1,33 @@ +/* + * 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 { firstValueFrom } from '@kbn/std'; +import type { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUi } from 'src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../../plugin'; +import { DEFAULT_OBJECT_NOUN } from '../components/constants'; + +export function createRedirectLegacyUrl( + getStartServices: StartServicesAccessor +): SpacesApiUi['redirectLegacyUrl'] { + return async function (path: string, objectNoun: string = DEFAULT_OBJECT_NOUN) { + const [{ notifications, application }] = await getStartServices(); + const { currentAppId$, navigateToApp } = application; + const appId = await firstValueFrom(currentAppId$); // retrieve the most recent value from the BehaviorSubject + + const title = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.title', { + defaultMessage: `We redirected you to a new URL`, + }); + const text = i18n.translate('xpack.spaces.shareToSpace.redirectLegacyUrlToast.text', { + defaultMessage: `The {objectNoun} you're looking for has a new location. Use this URL from now on.`, + values: { objectNoun }, + }); + notifications.toasts.addInfo({ title, text }); + await navigateToApp(appId!, { replace: true, path }); + }; +} diff --git a/x-pack/plugins/spaces/public/space_list/index.ts b/x-pack/plugins/spaces/public/space_list/index.ts new file mode 100644 index 00000000000000..1570ad123b9ab7 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/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 { getSpaceListComponent } from './space_list'; diff --git a/x-pack/plugins/spaces/public/space_list/space_list.tsx b/x-pack/plugins/spaces/public/space_list/space_list.tsx new file mode 100644 index 00000000000000..d8bd47b66b5c62 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { SpaceListInternal } from './space_list_internal'; + +export const getSpaceListComponent = (): React.FC => { + return (props: SpaceListProps) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx new file mode 100644 index 00000000000000..e0e8cc23373797 --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx @@ -0,0 +1,310 @@ +/* + * 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 { mountWithIntl } from '@kbn/test/jest'; +import { act } from '@testing-library/react'; +import { coreMock } from 'src/core/public/mocks'; +import type { Space } from 'src/plugins/spaces_oss/common'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { getSpacesContextWrapper } from '../spaces_context'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ReactWrapper } from 'enzyme'; +import { SpaceListInternal } from './space_list_internal'; + +const ACTIVE_SPACE: Space = { + id: 'default', + name: 'Default', + initials: 'D!', // so it can be differentiated from 'Delta' + disabledFeatures: [], +}; +const getSpaceData = (inactiveSpaceCount: number = 0) => { + const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] + .map((name) => { + const id = name.toLowerCase(); + return { id, name, disabledFeatures: [`${id}-feature`] }; + }) + .slice(0, inactiveSpaceCount); + const spaces = [ACTIVE_SPACE, ...inactive]; + const namespaces = spaces.map(({ id }) => id); + return { spaces, namespaces }; +}; + +/** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for any + * number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras (along + * with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it supersedes all + * of the above and just displays a single badge without a button. + */ +describe('SpaceListInternal', () => { + const createSpaceList = async ({ + spaces, + props, + feature, + }: { + spaces: Space[]; + props: SpaceListProps; + feature?: string; + }) => { + const { getStartServices } = coreMock.createSetup(); + const spacesManager = spacesManagerMock.create(); + spacesManager.getActiveSpace.mockResolvedValue(ACTIVE_SPACE); + spacesManager.getSpaces.mockResolvedValue(spaces); + + const SpacesContext = getSpacesContextWrapper({ getStartServices, spacesManager }); + const wrapper = mountWithIntl( + + + + ); + + // wait for context wrapper to rerender + await act(async () => {}); + wrapper.update(); + + return wrapper; + }; + + function getListText(wrapper: ReactWrapper) { + return wrapper.find('EuiFlexItem').map((x) => x.text()); + } + function getButton(wrapper: ReactWrapper) { + return wrapper.find('EuiButtonEmpty'); + } + + describe('using default properties', () => { + describe('with only the active space', () => { + const { spaces, namespaces } = getSpaceData(); + + it('does not show badges or button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toHaveLength(0); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and one inactive space', () => { + const { spaces, namespaces } = getSpaceData(1); + + it('shows one badge without button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and five inactive spaces', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { + const { spaces, namespaces } = getSpaceData(5); + + it('shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?', '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', '+2']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with the active space and six inactive spaces', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + + const button = getButton(wrapper); + expect(button.text()).toEqual('+1 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with the active space, six inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+2 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { + const { spaces, namespaces } = getSpaceData(6); + + it('shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?', '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+3 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', '+2']); + expect(button.text()).toEqual('show less'); + }); + }); + + describe('with only "all spaces"', () => { + it('shows one badge without button', async () => { + const props = { namespaces: ['*'] }; + const wrapper = await createSpaceList({ spaces: [], props }); + + expect(getListText(wrapper)).toEqual(['*']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + + describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { + // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else + const { spaces, namespaces } = getSpaceData(6); + + it('shows one badge without button', async () => { + const props = { namespaces: ['*', ...namespaces, '?'] }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['*']); + expect(getButton(wrapper)).toHaveLength(0); + }); + }); + }); + + describe('using custom properties', () => { + describe('with the active space, eight inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(8); + + it('with displayLimit=0, shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 0 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + + it('with displayLimit=1, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 1 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+8 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + + it('with displayLimit=7, shows badges with button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 7 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+2 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + + it('with displayLimit=8, shows badges without button', async () => { + const props = { namespaces: [...namespaces, '?'], displayLimit: 8 }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(getButton(wrapper)).toHaveLength(0); + }); + + it('with behaviorContext="outside-space", shows badges with button', async () => { + const props: SpaceListProps = { + namespaces: [...namespaces, '?'], + behaviorContext: 'outside-space', + }; + const wrapper = await createSpaceList({ spaces, props }); + + expect(getListText(wrapper)).toEqual(['D!', 'A', 'B', 'C', 'D']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+5 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['D!', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + }); + + describe('with a SpacesContext for a specific feature', () => { + describe('with the active space, eight inactive spaces, and one unauthorized space', () => { + const { spaces, namespaces } = getSpaceData(8); + + it('shows badges with button, showing disabled features at the end of the list', async () => { + // Each space that is generated by the getSpaceData function has a disabled feature derived from its own ID. + // E.g., the Alpha space has `disabledFeatures: ['alpha-feature']`, the Bravo space has `disabledFeatures: ['bravo-feature']`, and + // so on and so forth. For this test case we will render the Space context for the 'bravo-feature' feature, so the SpaceAvatar for + // the Bravo space will appear at the end of the list. + const props = { namespaces: [...namespaces, '?'] }; + const feature = 'bravo-feature'; + const wrapper = await createSpaceList({ spaces, props, feature }); + + expect(getListText(wrapper)).toEqual(['A', 'C', 'D', 'E', 'F']); + const button = getButton(wrapper); + expect(button.text()).toEqual('+4 more'); + + button.simulate('click'); + const badgeText = getListText(wrapper); + expect(badgeText).toEqual(['A', 'C', 'D', 'E', 'F', 'G', 'H', 'B', '+1']); + expect(button.text()).toEqual('show less'); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx new file mode 100644 index 00000000000000..b0250105885d2f --- /dev/null +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -0,0 +1,144 @@ +/* + * 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, ReactNode, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { SpaceListProps } from '../../../../../src/plugins/spaces_oss/public'; +import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; +import { useSpaces } from '../spaces_context'; +import { SpaceAvatar } from '../space_avatar'; + +const DEFAULT_DISPLAY_LIMIT = 5; + +/** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for any + * number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras (along + * with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it supersedes all + * of the above and just displays a single badge without a button. + */ +export const SpaceListInternal = ({ + namespaces, + displayLimit = DEFAULT_DISPLAY_LIMIT, + behaviorContext, +}: SpaceListProps) => { + const { shareToSpacesDataPromise } = useSpaces(); + + const [isExpanded, setIsExpanded] = useState(false); + const [shareToSpacesData, setShareToSpacesData] = useState(); + + useEffect(() => { + shareToSpacesDataPromise.then((x) => { + setShareToSpacesData(x); + }); + }, [shareToSpacesDataPromise]); + + if (!shareToSpacesData) { + return null; + } + + const isSharedToAllSpaces = namespaces.includes(ALL_SPACES_ID); + const unauthorizedSpacesCount = namespaces.filter((namespace) => namespace === UNKNOWN_SPACE) + .length; + let displayedSpaces: ShareToSpaceTarget[]; + let button: ReactNode = null; + + if (isSharedToAllSpaces) { + displayedSpaces = [ + { + id: ALL_SPACES_ID, + name: i18n.translate('xpack.spaces.spaceList.allSpacesLabel', { + defaultMessage: `* All spaces`, + }), + initials: '*', + color: '#D3DAE6', + }, + ]; + } else { + const authorized = namespaces.filter((namespace) => namespace !== UNKNOWN_SPACE); + const enabledSpaceTargets: ShareToSpaceTarget[] = []; + const disabledSpaceTargets: ShareToSpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = shareToSpacesData.spacesMap.get(namespace); + if (spaceTarget === undefined) { + // in the event that a new space was created after this page has loaded, fall back to displaying the space ID + enabledSpaceTargets.push({ id: namespace, name: namespace }); + } else if (behaviorContext === 'outside-space' || !spaceTarget.isActiveSpace) { + if (spaceTarget.isFeatureDisabled) { + disabledSpaceTargets.push(spaceTarget); + } else { + enabledSpaceTargets.push(spaceTarget); + } + } + }); + const authorizedSpaceTargets = [...enabledSpaceTargets, ...disabledSpaceTargets]; + + displayedSpaces = + isExpanded || !displayLimit + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, displayLimit); + + if (displayLimit && authorizedSpaceTargets.length > displayLimit) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + } + + const unauthorizedSpacesCountBadge = + !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedSpacesCount > 0 ? ( + + + } + > + +{unauthorizedSpacesCount} + + + ) : null; + + return ( + + {displayedSpaces.map((space) => { + // color may be undefined, which is intentional; SpacesAvatar calls the getSpaceColor function before rendering + const color = space.isFeatureDisabled ? 'hollow' : space.color; + return ( + + + + ); + })} + {unauthorizedSpacesCountBadge} + {button} + + ); +}; diff --git a/x-pack/plugins/spaces/public/spaces_context/context.tsx b/x-pack/plugins/spaces/public/spaces_context/context.tsx new file mode 100644 index 00000000000000..548b2158558c55 --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/context.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { SpacesManager } from '../spaces_manager'; +import { ShareToSpacesData } from '../types'; +import { SpacesReactContext, SpacesReactContextValue, KibanaServices } from './types'; + +const { useContext, createElement, createContext } = React; + +const context = createContext>>({}); + +export const useSpaces = (): SpacesReactContextValue< + KibanaServices & Extra +> => + useContext( + (context as unknown) as React.Context> + ); + +export const createSpacesReactContext = ( + services: Services, + spacesManager: SpacesManager, + shareToSpacesDataPromise: Promise +): SpacesReactContext => { + const value: SpacesReactContextValue = { + spacesManager, + shareToSpacesDataPromise, + services, + }; + const Provider: React.FC = ({ children }) => + createElement(context.Provider as React.ComponentType, { value, children }); + + return { + value, + Provider, + Consumer: (context.Consumer as unknown) as React.Consumer>, + }; +}; diff --git a/x-pack/plugins/spaces/public/spaces_context/index.ts b/x-pack/plugins/spaces/public/spaces_context/index.ts new file mode 100644 index 00000000000000..fdf28ad5957cfe --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/index.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 { useSpaces } from './context'; +export { getSpacesContextWrapper } from './wrapper'; diff --git a/x-pack/plugins/spaces/public/spaces_context/types.ts b/x-pack/plugins/spaces/public/spaces_context/types.ts new file mode 100644 index 00000000000000..c2f7db69add09c --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/types.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 * as React from 'react'; +import { CoreStart } from 'src/core/public'; +import { ShareToSpacesData } from '../types'; +import { SpacesManager } from '../spaces_manager'; + +export type KibanaServices = Partial; + +export interface SpacesReactContextValue { + readonly spacesManager: SpacesManager; + readonly shareToSpacesDataPromise: Promise; + readonly services: Services; +} + +export interface SpacesReactContext { + value: SpacesReactContextValue; + Provider: React.FC; + Consumer: React.Consumer>; +} diff --git a/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx new file mode 100644 index 00000000000000..18112945ea738c --- /dev/null +++ b/x-pack/plugins/spaces/public/spaces_context/wrapper.tsx @@ -0,0 +1,89 @@ +/* + * 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, useEffect, PropsWithChildren, useMemo } from 'react'; +import { + StartServicesAccessor, + DocLinksStart, + ApplicationStart, + NotificationsStart, +} from 'src/core/public'; +import type { SpacesContextProps } from '../../../../../src/plugins/spaces_oss/public'; +import { createSpacesReactContext } from './context'; +import { PluginsStart } from '../plugin'; +import { SpacesManager } from '../spaces_manager'; +import { ShareToSpacesData, ShareToSpaceTarget } from '../types'; +import { SpacesReactContext } from './types'; + +interface InternalProps { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +interface Services { + application: ApplicationStart; + docLinks: DocLinksStart; + notifications: NotificationsStart; +} + +async function getShareToSpacesData( + spacesManager: SpacesManager, + feature?: string +): Promise { + const spaces = await spacesManager.getSpaces({ includeAuthorizedPurposes: true }); + const activeSpace = await spacesManager.getActiveSpace(); + const spacesMap = spaces + .map(({ authorizedPurposes, disabledFeatures, ...space }) => { + const isActiveSpace = space.id === activeSpace.id; + const cannotShareToSpace = authorizedPurposes?.shareSavedObjectsIntoSpace === false; + const isFeatureDisabled = feature !== undefined && disabledFeatures.includes(feature); + return { + ...space, + ...(isActiveSpace && { isActiveSpace }), + ...(cannotShareToSpace && { cannotShareToSpace }), + ...(isFeatureDisabled && { isFeatureDisabled }), + }; + }) + .reduce((acc, cur) => acc.set(cur.id, cur), new Map()); + + return { + spacesMap, + activeSpaceId: activeSpace.id, + }; +} + +const SpacesContextWrapper = (props: PropsWithChildren) => { + const { spacesManager, getStartServices, feature, children } = props; + + const [context, setContext] = useState | undefined>(); + const shareToSpacesDataPromise = useMemo(() => getShareToSpacesData(spacesManager, feature), [ + spacesManager, + feature, + ]); + + useEffect(() => { + getStartServices().then(([coreStart]) => { + const { application, docLinks, notifications } = coreStart; + const services = { application, docLinks, notifications }; + setContext(createSpacesReactContext(services, spacesManager, shareToSpacesDataPromise)); + }); + }, [getStartServices, shareToSpacesDataPromise, spacesManager]); + + if (!context) { + return null; + } + + return {children}; +}; + +export const getSpacesContextWrapper = ( + internalProps: InternalProps +): React.FC => { + return ({ children, ...props }: PropsWithChildren) => { + return ; + }; +}; diff --git a/x-pack/plugins/spaces/public/types.ts b/x-pack/plugins/spaces/public/types.ts new file mode 100644 index 00000000000000..a49df82154849f --- /dev/null +++ b/x-pack/plugins/spaces/public/types.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetSpaceResult } from '../common'; + +/** + * The structure for all of the space data that must be loaded for share-to-space components to function. + */ +export interface ShareToSpacesData { + /** A map of each existing space's ID and its associated {@link ShareToSpaceTarget}. */ + readonly spacesMap: Map; + /** The ID of the active space. */ + readonly activeSpaceId: string; +} + +/** + * The data that was fetched for a specific space. Includes optional additional fields that are needed to handle edge cases in the + * share-to-space components that consume it. + */ +export interface ShareToSpaceTarget extends Omit { + /** True if this space is the active space. */ + isActiveSpace?: true; + /** True if the user has read access to this space, but is not authorized to share objects into this space. */ + cannotShareToSpace?: true; + /** True if the current feature (specified in the `SpacesContext`) is disabled in this space. */ + isFeatureDisabled?: true; +} diff --git a/x-pack/plugins/spaces/public/ui_api/components.ts b/x-pack/plugins/spaces/public/ui_api/components.ts new file mode 100644 index 00000000000000..6a8dedb5f5b683 --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/components.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUiComponent } from '../../../../../src/plugins/spaces_oss/public'; +import { PluginsStart } from '../plugin'; +import { + getShareToSpaceFlyoutComponent, + getLegacyUrlConflict, +} from '../share_saved_objects_to_space'; +import { getSpacesContextWrapper } from '../spaces_context'; +import { SpacesManager } from '../spaces_manager'; +import { getSpaceListComponent } from '../space_list'; + +export interface GetComponentsOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getComponents = ({ + spacesManager, + getStartServices, +}: GetComponentsOptions): SpacesApiUiComponent => { + return { + SpacesContext: getSpacesContextWrapper({ spacesManager, getStartServices }), + ShareToSpaceFlyout: getShareToSpaceFlyoutComponent(), + SpaceList: getSpaceListComponent(), + LegacyUrlConflict: getLegacyUrlConflict({ getStartServices }), + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/index.ts b/x-pack/plugins/spaces/public/ui_api/index.ts new file mode 100644 index 00000000000000..e278eb691910fe --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { StartServicesAccessor } from 'src/core/public'; +import type { SpacesApiUi } from '../../../../../src/plugins/spaces_oss/public'; +import type { PluginsStart } from '../plugin'; +import type { SpacesManager } from '../spaces_manager'; +import { getComponents } from './components'; +import { createRedirectLegacyUrl } from '../share_saved_objects_to_space'; + +interface GetUiApiOptions { + spacesManager: SpacesManager; + getStartServices: StartServicesAccessor; +} + +export const getUiApi = ({ spacesManager, getStartServices }: GetUiApiOptions): SpacesApiUi => { + const components = getComponents({ spacesManager, getStartServices }); + + return { + components, + redirectLegacyUrl: createRedirectLegacyUrl(getStartServices), + }; +}; diff --git a/x-pack/plugins/spaces/public/ui_api/mocks.ts b/x-pack/plugins/spaces/public/ui_api/mocks.ts new file mode 100644 index 00000000000000..c9aa2a2b2b52f9 --- /dev/null +++ b/x-pack/plugins/spaces/public/ui_api/mocks.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + SpacesApiUi, + SpacesApiUiComponent, +} from '../../../../../src/plugins/spaces_oss/public'; + +function createComponentsMock(): jest.Mocked { + return { + SpacesContext: jest.fn(), + ShareToSpaceFlyout: jest.fn(), + SpaceList: jest.fn(), + LegacyUrlConflict: jest.fn(), + }; +} + +function createUiApiMock(): jest.Mocked { + return { + components: createComponentsMock(), + redirectLegacyUrl: jest.fn(), + }; +} + +export const uiApiMock = { + create: createUiApiMock, +}; diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 8a749b5009334c..f5917e78135ecc 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -589,5 +589,57 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); }); + + describe('#openPointInTimeForType', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.openPointInTimeForType('foo', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { id: 'abc123' }; + baseClient.openPointInTimeForType.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.openPointInTimeForType('foo', options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.openPointInTimeForType).toHaveBeenCalledWith('foo', { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#closePointInTime', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.closePointInTime('foo', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { succeeded: true, num_freed: 1 }; + baseClient.closePointInTime.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.closePointInTime('foo', options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.closePointInTime).toHaveBeenCalledWith('foo', { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 9316d86b19bdd8..433f95d2b5cf6b 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -15,6 +15,8 @@ import { SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsFindOptions, + SavedObjectsClosePointInTimeOptions, + SavedObjectsOpenPointInTimeOptions, SavedObjectsUpdateOptions, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, @@ -378,4 +380,42 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { namespace: spaceIdToNamespace(this.spaceId), }); } + + /** + * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. + * The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. + * + * @param {string|Array} type + * @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions} + * @property {string} [options.keepAlive] + * @property {string} [options.preference] + * @returns {promise} - { id: string } + */ + async openPointInTimeForType( + type: string | string[], + options: SavedObjectsOpenPointInTimeOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + return await this.client.openPointInTimeForType(type, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + + /** + * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES + * via the Elasticsearch client, and is included in the Saved Objects Client + * as a convenience for consumers who are using `openPointInTimeForType`. + * + * @param {string} id - ID returned from `openPointInTimeForType` + * @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions} + * @returns {promise} - { succeeded: boolean; num_freed: number } + */ + async closePointInTime(id: string, options: SavedObjectsClosePointInTimeOptions = {}) { + throwErrorIfNamespaceSpecified(options); + return await this.client.closePointInTime(id, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx index 27ddb28eed779d..f475d97e2f39d3 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx @@ -147,6 +147,7 @@ describe('EsQueryAlertTypeExpression', () => { {}} setAlertProperty={() => {}} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx index 01c2bc18f35e8f..28f0f3db196143 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx @@ -89,6 +89,7 @@ describe('IndexThresholdAlertTypeExpression', () => { {}} setAlertProperty={() => {}} diff --git a/x-pack/plugins/task_manager/README.md b/x-pack/plugins/task_manager/README.md index 9be3be14ea3fca..c20bc4b29bcc84 100644 --- a/x-pack/plugins/task_manager/README.md +++ b/x-pack/plugins/task_manager/README.md @@ -85,10 +85,10 @@ export class Plugin { // This defaults to what is configured at the task manager level. maxAttempts: 5, - // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, - // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is - // overridden by the `override_num_workers` config value, if specified. - numWorkers: 2, + // The maximum number tasks of this type that can be run concurrently per Kibana instance. + // Setting this value will force Task Manager to poll for this task type seperatly from other task types which + // can add significant load to the ES cluster, so please use this configuration only when absolutly necesery. + maxConcurrency: 1, // The createTaskRunner function / method returns an object that is responsible for // performing the work of the task. context: { taskInstance }, is documented below. diff --git a/x-pack/plugins/task_manager/server/buffered_task_store.test.ts b/x-pack/plugins/task_manager/server/buffered_task_store.test.ts index 70d24b235d8805..45607713a31287 100644 --- a/x-pack/plugins/task_manager/server/buffered_task_store.test.ts +++ b/x-pack/plugins/task_manager/server/buffered_task_store.test.ts @@ -13,19 +13,17 @@ import { TaskStatus } from './task'; describe('Buffered Task Store', () => { test('proxies the TaskStore for `maxAttempts` and `remove`', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); taskStore.bulkUpdate.mockResolvedValue([]); const bufferedStore = new BufferedTaskStore(taskStore, {}); - expect(bufferedStore.maxAttempts).toEqual(10); - bufferedStore.remove('1'); expect(taskStore.remove).toHaveBeenCalledWith('1'); }); describe('update', () => { test("proxies the TaskStore's `bulkUpdate`", async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const task = mockTask(); @@ -37,7 +35,7 @@ describe('Buffered Task Store', () => { }); test('handles partially successfull bulkUpdates resolving each call appropriately', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const tasks = [mockTask(), mockTask(), mockTask()]; @@ -61,7 +59,7 @@ describe('Buffered Task Store', () => { }); test('handles multiple items with the same id', async () => { - const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const taskStore = taskStoreMock.create(); const bufferedStore = new BufferedTaskStore(taskStore, {}); const duplicateIdTask = mockTask(); diff --git a/x-pack/plugins/task_manager/server/buffered_task_store.ts b/x-pack/plugins/task_manager/server/buffered_task_store.ts index 4e4a533303867f..ca735dd6f36389 100644 --- a/x-pack/plugins/task_manager/server/buffered_task_store.ts +++ b/x-pack/plugins/task_manager/server/buffered_task_store.ts @@ -26,10 +26,6 @@ export class BufferedTaskStore implements Updatable { ); } - public get maxAttempts(): number { - return this.taskStore.maxAttempts; - } - public async update(doc: ConcreteTaskInstance): Promise { return unwrapPromise(this.bufferedUpdate(doc)); } diff --git a/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts index 79a0d2f6900429..8e0396a453b3d9 100644 --- a/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts +++ b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts @@ -10,27 +10,32 @@ import sinon from 'sinon'; import { fillPool, FillPoolResult } from './fill_pool'; import { TaskPoolRunResult } from '../task_pool'; import { asOk, Result } from './result_type'; -import { ClaimOwnershipResult } from '../task_store'; import { ConcreteTaskInstance, TaskStatus } from '../task'; import { TaskManagerRunner } from '../task_running/task_runner'; +import { from, Observable } from 'rxjs'; +import { ClaimOwnershipResult } from '../queries/task_claiming'; jest.mock('../task_running/task_runner'); describe('fillPool', () => { function mockFetchAvailableTasks( tasksToMock: number[][] - ): () => Promise> { - const tasks: ConcreteTaskInstance[][] = tasksToMock.map((ids) => mockTaskInstances(ids)); - let index = 0; - return async () => - asOk({ - stats: { - tasksUpdated: tasks[index + 1]?.length ?? 0, - tasksConflicted: 0, - tasksClaimed: 0, - }, - docs: tasks[index++] || [], - }); + ): () => Observable> { + const claimCycles: ConcreteTaskInstance[][] = tasksToMock.map((ids) => mockTaskInstances(ids)); + return () => + from( + claimCycles.map((tasks) => + asOk({ + stats: { + tasksUpdated: tasks?.length ?? 0, + tasksConflicted: 0, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: tasks, + }) + ) + ); } const mockTaskInstances = (ids: number[]): ConcreteTaskInstance[] => @@ -51,7 +56,7 @@ describe('fillPool', () => { ownerId: null, })); - test('stops filling when pool runs all claimed tasks, even if there is more capacity', async () => { + test('fills task pool with all claimed tasks until fetchAvailableTasks stream closes', async () => { const tasks = [ [1, 2, 3], [4, 5], @@ -62,21 +67,7 @@ describe('fillPool', () => { await fillPool(fetchAvailableTasks, converter, run); - expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3])); - }); - - test('stops filling when the pool has no more capacity', async () => { - const tasks = [ - [1, 2, 3], - [4, 5], - ]; - const fetchAvailableTasks = mockFetchAvailableTasks(tasks); - const run = sinon.spy(async () => TaskPoolRunResult.RanOutOfCapacity); - const converter = _.identity; - - await fillPool(fetchAvailableTasks, converter, run); - - expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3])); + expect(_.flattenDeep(run.args)).toEqual(mockTaskInstances([1, 2, 3, 4, 5])); }); test('calls the converter on the records prior to running', async () => { @@ -91,7 +82,7 @@ describe('fillPool', () => { await fillPool(fetchAvailableTasks, converter, run); - expect(_.flattenDeep(run.args)).toEqual(['1', '2', '3']); + expect(_.flattenDeep(run.args)).toEqual(['1', '2', '3', '4', '5']); }); describe('error handling', () => { @@ -101,7 +92,10 @@ describe('fillPool', () => { (instance.id as unknown) as TaskManagerRunner; try { - const fetchAvailableTasks = async () => Promise.reject('fetch is not working'); + const fetchAvailableTasks = () => + new Observable>((obs) => + obs.error('fetch is not working') + ); await fillPool(fetchAvailableTasks, converter, run); } catch (err) { diff --git a/x-pack/plugins/task_manager/server/lib/fill_pool.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.ts index 45a33081bde51e..c9050ebb75d69f 100644 --- a/x-pack/plugins/task_manager/server/lib/fill_pool.ts +++ b/x-pack/plugins/task_manager/server/lib/fill_pool.ts @@ -6,12 +6,14 @@ */ import { performance } from 'perf_hooks'; +import { Observable } from 'rxjs'; +import { concatMap, last } from 'rxjs/operators'; +import { ClaimOwnershipResult } from '../queries/task_claiming'; import { ConcreteTaskInstance } from '../task'; import { WithTaskTiming, startTaskTimer } from '../task_events'; import { TaskPoolRunResult } from '../task_pool'; import { TaskManagerRunner } from '../task_running'; -import { ClaimOwnershipResult } from '../task_store'; -import { Result, map } from './result_type'; +import { Result, map as mapResult, asErr, asOk } from './result_type'; export enum FillPoolResult { Failed = 'Failed', @@ -22,6 +24,17 @@ export enum FillPoolResult { PoolFilled = 'PoolFilled', } +type FillPoolAndRunResult = Result< + { + result: TaskPoolRunResult; + stats?: ClaimOwnershipResult['stats']; + }, + { + result: FillPoolResult; + stats?: ClaimOwnershipResult['stats']; + } +>; + export type ClaimAndFillPoolResult = Partial> & { result: FillPoolResult; }; @@ -40,52 +53,81 @@ export type TimedFillPoolResult = WithTaskTiming; * @param converter - a function that converts task records to the appropriate task runner */ export async function fillPool( - fetchAvailableTasks: () => Promise>, + fetchAvailableTasks: () => Observable>, converter: (taskInstance: ConcreteTaskInstance) => TaskManagerRunner, run: (tasks: TaskManagerRunner[]) => Promise ): Promise { performance.mark('fillPool.start'); - const stopTaskTimer = startTaskTimer(); - const augmentTimingTo = ( - result: FillPoolResult, - stats?: ClaimOwnershipResult['stats'] - ): TimedFillPoolResult => ({ - result, - stats, - timing: stopTaskTimer(), - }); - return map>( - await fetchAvailableTasks(), - async ({ docs, stats }) => { - if (!docs.length) { - performance.mark('fillPool.bailNoTasks'); - performance.measure( - 'fillPool.activityDurationUntilNoTasks', - 'fillPool.start', - 'fillPool.bailNoTasks' - ); - return augmentTimingTo(FillPoolResult.NoTasksClaimed, stats); - } - - const tasks = docs.map(converter); - - switch (await run(tasks)) { - case TaskPoolRunResult.RanOutOfCapacity: - performance.mark('fillPool.bailExhaustedCapacity'); - performance.measure( - 'fillPool.activityDurationUntilExhaustedCapacity', - 'fillPool.start', - 'fillPool.bailExhaustedCapacity' + return new Promise((resolve, reject) => { + const stopTaskTimer = startTaskTimer(); + const augmentTimingTo = ( + result: FillPoolResult, + stats?: ClaimOwnershipResult['stats'] + ): TimedFillPoolResult => ({ + result, + stats, + timing: stopTaskTimer(), + }); + fetchAvailableTasks() + .pipe( + // each ClaimOwnershipResult will be sequencially consumed an ran using the `run` handler + concatMap(async (res) => + mapResult>( + res, + async ({ docs, stats }) => { + if (!docs.length) { + performance.mark('fillPool.bailNoTasks'); + performance.measure( + 'fillPool.activityDurationUntilNoTasks', + 'fillPool.start', + 'fillPool.bailNoTasks' + ); + return asOk({ result: TaskPoolRunResult.NoTaskWereRan, stats }); + } + return asOk( + await run(docs.map(converter)).then((runResult) => ({ + result: runResult, + stats, + })) + ); + }, + async (fillPoolResult) => asErr({ result: fillPoolResult }) + ) + ), + // when the final call to `run` completes, we'll complete the stream and emit the + // final accumulated result + last() + ) + .subscribe( + (claimResults) => { + resolve( + mapResult( + claimResults, + ({ result, stats }) => { + switch (result) { + case TaskPoolRunResult.RanOutOfCapacity: + performance.mark('fillPool.bailExhaustedCapacity'); + performance.measure( + 'fillPool.activityDurationUntilExhaustedCapacity', + 'fillPool.start', + 'fillPool.bailExhaustedCapacity' + ); + return augmentTimingTo(FillPoolResult.RanOutOfCapacity, stats); + case TaskPoolRunResult.RunningAtCapacity: + performance.mark('fillPool.cycle'); + return augmentTimingTo(FillPoolResult.RunningAtCapacity, stats); + case TaskPoolRunResult.NoTaskWereRan: + return augmentTimingTo(FillPoolResult.NoTasksClaimed, stats); + default: + performance.mark('fillPool.cycle'); + return augmentTimingTo(FillPoolResult.PoolFilled, stats); + } + }, + ({ result, stats }) => augmentTimingTo(result, stats) + ) ); - return augmentTimingTo(FillPoolResult.RanOutOfCapacity, stats); - case TaskPoolRunResult.RunningAtCapacity: - performance.mark('fillPool.cycle'); - return augmentTimingTo(FillPoolResult.RunningAtCapacity, stats); - default: - performance.mark('fillPool.cycle'); - return augmentTimingTo(FillPoolResult.PoolFilled, stats); - } - }, - async (result) => augmentTimingTo(result) - ); + }, + (err) => reject(err) + ); + }); } diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts index 5c32c3e7225c43..7040d5acd4eaf3 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts @@ -537,6 +537,7 @@ describe('Task Run Statistics', () => { asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed, timing })) ); events$.next(asTaskManagerStatEvent('pollingDelay', asOk(0))); + events$.next(asTaskManagerStatEvent('claimDuration', asOk(10))); events$.next( asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed, timing })) ); diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts index 4b7bdf595f1f55..3185d3c449c32c 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts @@ -19,6 +19,7 @@ import { RanTask, TaskTiming, isTaskManagerStatEvent, + TaskManagerStat, } from '../task_events'; import { isOk, Ok, unwrap } from '../lib/result_type'; import { ConcreteTaskInstance } from '../task'; @@ -39,6 +40,7 @@ interface FillPoolStat extends JsonObject { last_successful_poll: string; last_polling_delay: string; duration: number[]; + claim_duration: number[]; claim_conflicts: number[]; claim_mismatches: number[]; result_frequency_percent_as_number: FillPoolResult[]; @@ -51,6 +53,7 @@ interface ExecutionStat extends JsonObject { export interface TaskRunStat extends JsonObject { drift: number[]; + drift_by_type: Record; load: number[]; execution: ExecutionStat; polling: Omit & @@ -125,6 +128,7 @@ export function createTaskRunAggregator( const resultFrequencyQueue = createRunningAveragedStat(runningAverageWindowSize); const pollingDurationQueue = createRunningAveragedStat(runningAverageWindowSize); + const claimDurationQueue = createRunningAveragedStat(runningAverageWindowSize); const claimConflictsQueue = createRunningAveragedStat(runningAverageWindowSize); const claimMismatchesQueue = createRunningAveragedStat(runningAverageWindowSize); const taskPollingEvents$: Observable> = combineLatest([ @@ -168,10 +172,26 @@ export function createTaskRunAggregator( ), map(() => new Date().toISOString()) ), + // get duration of task claim stage in polling + taskPollingLifecycle.events.pipe( + filter( + (taskEvent: TaskLifecycleEvent) => + isTaskManagerStatEvent(taskEvent) && + taskEvent.id === 'claimDuration' && + isOk(taskEvent.event) + ), + map((claimDurationEvent) => { + const duration = ((claimDurationEvent as TaskManagerStat).event as Ok).value; + return { + claimDuration: duration ? claimDurationQueue(duration) : claimDurationQueue(), + }; + }) + ), ]).pipe( - map(([{ polling }, pollingDelay]) => ({ + map(([{ polling }, pollingDelay, { claimDuration }]) => ({ polling: { last_polling_delay: pollingDelay, + claim_duration: claimDuration, ...polling, }, })) @@ -179,13 +199,18 @@ export function createTaskRunAggregator( return combineLatest([ taskRunEvents$.pipe( - startWith({ drift: [], execution: { duration: {}, result_frequency_percent_as_number: {} } }) + startWith({ + drift: [], + drift_by_type: {}, + execution: { duration: {}, result_frequency_percent_as_number: {} }, + }) ), taskManagerLoadStatEvents$.pipe(startWith({ load: [] })), taskPollingEvents$.pipe( startWith({ polling: { duration: [], + claim_duration: [], claim_conflicts: [], claim_mismatches: [], result_frequency_percent_as_number: [], @@ -218,6 +243,7 @@ function hasTiming(taskEvent: TaskLifecycleEvent) { function createTaskRunEventToStat(runningAverageWindowSize: number) { const driftQueue = createRunningAveragedStat(runningAverageWindowSize); + const driftByTaskQueue = createMapOfRunningAveragedStats(runningAverageWindowSize); const taskRunDurationQueue = createMapOfRunningAveragedStats(runningAverageWindowSize); const resultFrequencyQueue = createMapOfRunningAveragedStats( runningAverageWindowSize @@ -226,13 +252,17 @@ function createTaskRunEventToStat(runningAverageWindowSize: number) { task: ConcreteTaskInstance, timing: TaskTiming, result: TaskRunResult - ): Omit => ({ - drift: driftQueue(timing!.start - task.runAt.getTime()), - execution: { - duration: taskRunDurationQueue(task.taskType, timing!.stop - timing!.start), - result_frequency_percent_as_number: resultFrequencyQueue(task.taskType, result), - }, - }); + ): Omit => { + const drift = timing!.start - task.runAt.getTime(); + return { + drift: driftQueue(drift), + drift_by_type: driftByTaskQueue(task.taskType, drift), + execution: { + duration: taskRunDurationQueue(task.taskType, timing!.stop - timing!.start), + result_frequency_percent_as_number: resultFrequencyQueue(task.taskType, result), + }, + }; + }; } const DEFAULT_TASK_RUN_FREQUENCIES = { @@ -258,11 +288,15 @@ export function summarizeTaskRunStat( // eslint-disable-next-line @typescript-eslint/naming-convention last_polling_delay, duration: pollingDuration, + // eslint-disable-next-line @typescript-eslint/naming-convention + claim_duration, result_frequency_percent_as_number: pollingResultFrequency, claim_conflicts: claimConflicts, claim_mismatches: claimMismatches, }, drift, + // eslint-disable-next-line @typescript-eslint/naming-convention + drift_by_type, load, execution: { duration, result_frequency_percent_as_number: executionResultFrequency }, }: TaskRunStat, @@ -273,6 +307,9 @@ export function summarizeTaskRunStat( polling: { ...(last_successful_poll ? { last_successful_poll } : {}), ...(last_polling_delay ? { last_polling_delay } : {}), + ...(claim_duration + ? { claim_duration: calculateRunningAverage(claim_duration as number[]) } + : {}), duration: calculateRunningAverage(pollingDuration as number[]), claim_conflicts: calculateRunningAverage(claimConflicts as number[]), claim_mismatches: calculateRunningAverage(claimMismatches as number[]), @@ -282,6 +319,7 @@ export function summarizeTaskRunStat( }, }, drift: calculateRunningAverage(drift), + drift_by_type: mapValues(drift_by_type, (typedDrift) => calculateRunningAverage(typedDrift)), load: calculateRunningAverage(load), execution: { duration: mapValues(duration, (typedDurations) => calculateRunningAverage(typedDurations)), diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index 0a879ce92cba6e..45db18a3e83857 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -70,6 +70,15 @@ describe('TaskManagerPlugin', () => { const setupApi = await taskManagerPlugin.setup(coreMock.createSetup()); + // we only start a poller if we have task types that we support and we track + // phases (moving from Setup to Start) based on whether the poller is working + setupApi.registerTaskDefinitions({ + setupTimeType: { + title: 'setupTimeType', + createTaskRunner: () => ({ async run() {} }), + }, + }); + await taskManagerPlugin.start(coreMock.createStart()); expect(() => diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 149d111b08f02a..507a021214a904 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -16,13 +16,12 @@ import { ServiceStatusLevels, CoreStatus, } from '../../../../src/core/server'; -import { TaskDefinition } from './task'; import { TaskPollingLifecycle } from './polling_lifecycle'; import { TaskManagerConfig } from './config'; import { createInitialMiddleware, addMiddlewareToChain, Middleware } from './lib/middleware'; import { removeIfExists } from './lib/remove_if_exists'; import { setupSavedObjects } from './saved_objects'; -import { TaskTypeDictionary } from './task_type_dictionary'; +import { TaskDefinitionRegistry, TaskTypeDictionary } from './task_type_dictionary'; import { FetchResult, SearchOpts, TaskStore } from './task_store'; import { createManagedConfiguration } from './lib/create_managed_configuration'; import { TaskScheduling } from './task_scheduling'; @@ -100,7 +99,7 @@ export class TaskManagerPlugin this.assertStillInSetup('add Middleware'); this.middleware = addMiddlewareToChain(this.middleware, middleware); }, - registerTaskDefinitions: (taskDefinition: Record) => { + registerTaskDefinitions: (taskDefinition: TaskDefinitionRegistry) => { this.assertStillInSetup('register task definitions'); this.definitions.registerTaskDefinitions(taskDefinition); }, @@ -110,12 +109,12 @@ export class TaskManagerPlugin public start({ savedObjects, elasticsearch }: CoreStart): TaskManagerStartContract { const savedObjectsRepository = savedObjects.createInternalRepository(['task']); + const serializer = savedObjects.createSerializer(); const taskStore = new TaskStore({ - serializer: savedObjects.createSerializer(), + serializer, savedObjectsRepository, esClient: elasticsearch.createClient('taskManager').asInternalUser, index: this.config!.index, - maxAttempts: this.config!.max_attempts, definitions: this.definitions, taskManagerId: `kibana:${this.taskManagerId!}`, }); @@ -151,6 +150,7 @@ export class TaskManagerPlugin taskStore, middleware: this.middleware, taskPollingLifecycle: this.taskPollingLifecycle, + definitions: this.definitions, }); return { diff --git a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts index d4617d6549d60d..f3af6f50336eae 100644 --- a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts +++ b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.test.ts @@ -64,6 +64,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 8, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -79,6 +80,63 @@ describe('delayOnClaimConflicts', () => { }) ); + test( + 'emits delay only once, no mater how many subscribers there are', + fakeSchedulers(async () => { + const taskLifecycleEvents$ = new Subject(); + + const delays$ = delayOnClaimConflicts(of(10), of(100), taskLifecycleEvents$, 80, 2); + + const firstSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + const secondSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + + taskLifecycleEvents$.next( + asTaskPollingCycleEvent( + asOk({ + result: FillPoolResult.PoolFilled, + stats: { + tasksUpdated: 0, + tasksConflicted: 8, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }) + ) + ); + + const thirdSubscriber$ = delays$.pipe(take(2), bufferCount(2)).toPromise(); + + taskLifecycleEvents$.next( + asTaskPollingCycleEvent( + asOk({ + result: FillPoolResult.PoolFilled, + stats: { + tasksUpdated: 0, + tasksConflicted: 10, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }) + ) + ); + + // should get the initial value of 0 delay + const [initialDelay, firstRandom] = await firstSubscriber$; + // should get the 0 delay (as a replay), which was the last value plus the first random value + const [initialDelayInSecondSub, firstRandomInSecondSub] = await secondSubscriber$; + // should get the first random value (as a replay) and the next random value + const [firstRandomInThirdSub, secondRandomInThirdSub] = await thirdSubscriber$; + + expect(initialDelay).toEqual(0); + expect(initialDelayInSecondSub).toEqual(0); + expect(firstRandom).toEqual(firstRandomInSecondSub); + expect(firstRandomInSecondSub).toEqual(firstRandomInThirdSub); + expect(secondRandomInThirdSub).toBeGreaterThanOrEqual(0); + }) + ); + test( 'doesnt emit a new delay when conflicts have reduced', fakeSchedulers(async () => { @@ -107,6 +165,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 8, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -127,6 +186,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 7, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) @@ -145,6 +205,7 @@ describe('delayOnClaimConflicts', () => { tasksUpdated: 0, tasksConflicted: 9, tasksClaimed: 0, + tasksRejected: 0, }, docs: [], }) diff --git a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts index 73e7052b65a69e..6d7cb77625b580 100644 --- a/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts +++ b/x-pack/plugins/task_manager/server/polling/delay_on_claim_conflicts.ts @@ -11,7 +11,7 @@ import stats from 'stats-lite'; import { isNumber, random } from 'lodash'; -import { merge, of, Observable, combineLatest } from 'rxjs'; +import { merge, of, Observable, combineLatest, ReplaySubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { Option, none, some, isSome, Some } from 'fp-ts/lib/Option'; import { isOk } from '../lib/result_type'; @@ -32,7 +32,9 @@ export function delayOnClaimConflicts( runningAverageWindowSize: number ): Observable { const claimConflictQueue = createRunningAveragedStat(runningAverageWindowSize); - return merge( + // return a subject to allow multicast and replay the last value to new subscribers + const multiCastDelays$ = new ReplaySubject(1); + merge( of(0), combineLatest([ maxWorkersConfiguration$, @@ -70,5 +72,9 @@ export function delayOnClaimConflicts( return random(pollInterval * 0.25, pollInterval * 0.75, false); }) ) - ); + ).subscribe((delay) => { + multiCastDelays$.next(delay); + }); + + return multiCastDelays$; } diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index 9f794450702379..63d7f6de81801f 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -7,17 +7,30 @@ import _ from 'lodash'; import sinon from 'sinon'; -import { of, Subject } from 'rxjs'; +import { Observable, of, Subject } from 'rxjs'; import { TaskPollingLifecycle, claimAvailableTasks } from './polling_lifecycle'; import { createInitialMiddleware } from './lib/middleware'; import { TaskTypeDictionary } from './task_type_dictionary'; import { taskStoreMock } from './task_store.mock'; import { mockLogger } from './test_utils'; +import { taskClaimingMock } from './queries/task_claiming.mock'; +import { TaskClaiming, ClaimOwnershipResult } from './queries/task_claiming'; +import type { TaskClaiming as TaskClaimingClass } from './queries/task_claiming'; +import { asOk, Err, isErr, isOk, Result } from './lib/result_type'; +import { FillPoolResult } from './lib/fill_pool'; + +let mockTaskClaiming = taskClaimingMock.create({}); +jest.mock('./queries/task_claiming', () => { + return { + TaskClaiming: jest.fn().mockImplementation(() => { + return mockTaskClaiming; + }), + }; +}); describe('TaskPollingLifecycle', () => { let clock: sinon.SinonFakeTimers; - const taskManagerLogger = mockLogger(); const mockTaskStore = taskStoreMock.create({}); const taskManagerOpts = { @@ -50,8 +63,9 @@ describe('TaskPollingLifecycle', () => { }; beforeEach(() => { + mockTaskClaiming = taskClaimingMock.create({}); + (TaskClaiming as jest.Mock).mockClear(); clock = sinon.useFakeTimers(); - taskManagerOpts.definitions = new TaskTypeDictionary(taskManagerLogger); }); afterEach(() => clock.restore()); @@ -60,17 +74,58 @@ describe('TaskPollingLifecycle', () => { test('begins polling once the ES and SavedObjects services are available', () => { const elasticsearchAndSOAvailability$ = new Subject(); new TaskPollingLifecycle({ - elasticsearchAndSOAvailability$, ...taskManagerOpts, + elasticsearchAndSOAvailability$, }); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); + }); + + test('provides TaskClaiming with the capacity available', () => { + const elasticsearchAndSOAvailability$ = new Subject(); + const maxWorkers$ = new Subject(); + taskManagerOpts.definitions.registerTaskDefinitions({ + report: { + title: 'report', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + quickReport: { + title: 'quickReport', + maxConcurrency: 5, + createTaskRunner: jest.fn(), + }, + }); + + new TaskPollingLifecycle({ + ...taskManagerOpts, + elasticsearchAndSOAvailability$, + maxWorkersConfiguration$: maxWorkers$, + }); + + const taskClaimingGetCapacity = (TaskClaiming as jest.Mock).mock + .calls[0][0].getCapacity; + + maxWorkers$.next(20); + expect(taskClaimingGetCapacity()).toEqual(20); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(5); + + maxWorkers$.next(30); + expect(taskClaimingGetCapacity()).toEqual(30); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(5); + + maxWorkers$.next(2); + expect(taskClaimingGetCapacity()).toEqual(2); + expect(taskClaimingGetCapacity('report')).toEqual(1); + expect(taskClaimingGetCapacity('quickReport')).toEqual(2); }); }); @@ -85,13 +140,13 @@ describe('TaskPollingLifecycle', () => { elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(false); - mockTaskStore.claimAvailableTasks.mockClear(); + mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockClear(); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); }); test('restarts polling once the ES and SavedObjects services become available again', () => { @@ -104,68 +159,64 @@ describe('TaskPollingLifecycle', () => { elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(false); - mockTaskStore.claimAvailableTasks.mockClear(); + mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockClear(); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).not.toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).not.toHaveBeenCalled(); elasticsearchAndSOAvailability$.next(true); clock.tick(150); - expect(mockTaskStore.claimAvailableTasks).toHaveBeenCalled(); + expect(mockTaskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalled(); }); }); describe('claimAvailableTasks', () => { - test('should claim Available Tasks when there are available workers', () => { - const logger = mockLogger(); - const claim = jest.fn(() => - Promise.resolve({ - docs: [], - stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0 }, - }) - ); - - const availableWorkers = 1; - - claimAvailableTasks([], claim, availableWorkers, logger); - - expect(claim).toHaveBeenCalledTimes(1); - }); - - test('should not claim Available Tasks when there are no available workers', () => { + test('should claim Available Tasks when there are available workers', async () => { const logger = mockLogger(); - const claim = jest.fn(() => - Promise.resolve({ - docs: [], - stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0 }, - }) + const taskClaiming = taskClaimingMock.create({}); + taskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockImplementation(() => + of( + asOk({ + docs: [], + stats: { tasksUpdated: 0, tasksConflicted: 0, tasksClaimed: 0, tasksRejected: 0 }, + }) + ) ); - const availableWorkers = 0; + expect( + isOk(await getFirstAsPromise(claimAvailableTasks([], taskClaiming, logger))) + ).toBeTruthy(); - claimAvailableTasks([], claim, availableWorkers, logger); - - expect(claim).not.toHaveBeenCalled(); + expect(taskClaiming.claimAvailableTasksIfCapacityIsAvailable).toHaveBeenCalledTimes(1); }); /** * This handles the case in which Elasticsearch has had inline script disabled. * This is achieved by setting the `script.allowed_types` flag on Elasticsearch to `none` */ - test('handles failure due to inline scripts being disabled', () => { + test('handles failure due to inline scripts being disabled', async () => { const logger = mockLogger(); - const claim = jest.fn(() => { - throw Object.assign(new Error(), { - response: - '{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}],"type":"search_phase_execution_exception","reason":"all shards failed","phase":"query","grouped":true,"failed_shards":[{"shard":0,"index":".kibana_task_manager_1","node":"24A4QbjHSK6prvtopAKLKw","reason":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}],"caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts","caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}},"status":400}', - }); - }); + const taskClaiming = taskClaimingMock.create({}); + taskClaiming.claimAvailableTasksIfCapacityIsAvailable.mockImplementation( + () => + new Observable>((observer) => { + observer.error( + Object.assign(new Error(), { + response: + '{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}],"type":"search_phase_execution_exception","reason":"all shards failed","phase":"query","grouped":true,"failed_shards":[{"shard":0,"index":".kibana_task_manager_1","node":"24A4QbjHSK6prvtopAKLKw","reason":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}],"caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts","caused_by":{"type":"illegal_argument_exception","reason":"cannot execute [inline] scripts"}}},"status":400}', + }) + ); + }) + ); + + const err = await getFirstAsPromise(claimAvailableTasks([], taskClaiming, logger)); - claimAvailableTasks([], claim, 10, logger); + expect(isErr(err)).toBeTruthy(); + expect((err as Err).error).toEqual(FillPoolResult.Failed); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledWith( @@ -174,3 +225,9 @@ describe('TaskPollingLifecycle', () => { }); }); }); + +function getFirstAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.subscribe(resolve, reject); + }); +} diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index db8eeaaf78dee5..260f5ccc70f53c 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -6,15 +6,12 @@ */ import { Subject, Observable, Subscription } from 'rxjs'; - -import { performance } from 'perf_hooks'; - import { pipe } from 'fp-ts/lib/pipeable'; import { Option, some, map as mapOptional } from 'fp-ts/lib/Option'; import { tap } from 'rxjs/operators'; import { Logger } from '../../../../src/core/server'; -import { Result, asErr, mapErr, asOk, map } from './lib/result_type'; +import { Result, asErr, mapErr, asOk, map, mapOk } from './lib/result_type'; import { ManagedConfiguration } from './lib/create_managed_configuration'; import { TaskManagerConfig } from './config'; @@ -41,11 +38,12 @@ import { } from './polling'; import { TaskPool } from './task_pool'; import { TaskManagerRunner, TaskRunner } from './task_running'; -import { TaskStore, OwnershipClaimingOpts, ClaimOwnershipResult } from './task_store'; +import { TaskStore } from './task_store'; import { identifyEsError } from './lib/identify_es_error'; import { BufferedTaskStore } from './buffered_task_store'; import { TaskTypeDictionary } from './task_type_dictionary'; import { delayOnClaimConflicts } from './polling'; +import { TaskClaiming, ClaimOwnershipResult } from './queries/task_claiming'; export type TaskPollingLifecycleOpts = { logger: Logger; @@ -71,6 +69,7 @@ export class TaskPollingLifecycle { private definitions: TaskTypeDictionary; private store: TaskStore; + private taskClaiming: TaskClaiming; private bufferedStore: BufferedTaskStore; private logger: Logger; @@ -106,8 +105,6 @@ export class TaskPollingLifecycle { this.store = taskStore; const emitEvent = (event: TaskLifecycleEvent) => this.events$.next(event); - // pipe store events into the lifecycle event stream - this.store.events.subscribe(emitEvent); this.bufferedStore = new BufferedTaskStore(this.store, { bufferMaxOperations: config.max_workers, @@ -120,6 +117,26 @@ export class TaskPollingLifecycle { }); this.pool.load.subscribe(emitEvent); + this.taskClaiming = new TaskClaiming({ + taskStore, + maxAttempts: config.max_attempts, + definitions, + logger: this.logger, + getCapacity: (taskType?: string) => + taskType && this.definitions.get(taskType)?.maxConcurrency + ? Math.max( + Math.min( + this.pool.availableWorkers, + this.definitions.get(taskType)!.maxConcurrency! - + this.pool.getOccupiedWorkersByType(taskType) + ), + 0 + ) + : this.pool.availableWorkers, + }); + // pipe taskClaiming events into the lifecycle event stream + this.taskClaiming.events.subscribe(emitEvent); + const { max_poll_inactivity_cycles: maxPollInactivityCycles, poll_interval: pollInterval, @@ -199,6 +216,7 @@ export class TaskPollingLifecycle { beforeRun: this.middleware.beforeRun, beforeMarkRunning: this.middleware.beforeMarkRunning, onTaskEvent: this.emitEvent, + defaultMaxAttempts: this.taskClaiming.maxAttempts, }); }; @@ -212,9 +230,18 @@ export class TaskPollingLifecycle { () => claimAvailableTasks( tasksToClaim.splice(0, this.pool.availableWorkers), - this.store.claimAvailableTasks, - this.pool.availableWorkers, + this.taskClaiming, this.logger + ).pipe( + tap( + mapOk(({ timing }: ClaimOwnershipResult) => { + if (timing) { + this.emitEvent( + asTaskManagerStatEvent('claimDuration', asOk(timing.stop - timing.start)) + ); + } + }) + ) ), // wrap each task in a Task Runner this.createTaskRunnerForTask, @@ -252,59 +279,40 @@ export class TaskPollingLifecycle { } } -export async function claimAvailableTasks( +export function claimAvailableTasks( claimTasksById: string[], - claim: (opts: OwnershipClaimingOpts) => Promise, - availableWorkers: number, + taskClaiming: TaskClaiming, logger: Logger -): Promise> { - if (availableWorkers > 0) { - performance.mark('claimAvailableTasks_start'); - - try { - const claimResult = await claim({ - size: availableWorkers, +): Observable> { + return new Observable((observer) => { + taskClaiming + .claimAvailableTasksIfCapacityIsAvailable({ claimOwnershipUntil: intervalFromNow('30s')!, claimTasksById, - }); - const { - docs, - stats: { tasksClaimed }, - } = claimResult; - - if (tasksClaimed === 0) { - performance.mark('claimAvailableTasks.noTasks'); - } - performance.mark('claimAvailableTasks_stop'); - performance.measure( - 'claimAvailableTasks', - 'claimAvailableTasks_start', - 'claimAvailableTasks_stop' + }) + .subscribe( + (claimResult) => { + observer.next(claimResult); + }, + (ex) => { + // if the `taskClaiming` stream errors out we want to catch it and see if + // we can identify the reason + // if we can - we emit an FillPoolResult error rather than erroring out the wrapping Observable + // returned by `claimAvailableTasks` + if (identifyEsError(ex).includes('cannot execute [inline] scripts')) { + logger.warn( + `Task Manager cannot operate when inline scripts are disabled in Elasticsearch` + ); + observer.next(asErr(FillPoolResult.Failed)); + observer.complete(); + } else { + // as we could't identify the reason - we'll error out the wrapping Observable too + observer.error(ex); + } + }, + () => { + observer.complete(); + } ); - - if (docs.length !== tasksClaimed) { - logger.warn( - `[Task Ownership error]: ${tasksClaimed} tasks were claimed by Kibana, but ${ - docs.length - } task(s) were fetched (${docs.map((doc) => doc.id).join(', ')})` - ); - } - return asOk(claimResult); - } catch (ex) { - if (identifyEsError(ex).includes('cannot execute [inline] scripts')) { - logger.warn( - `Task Manager cannot operate when inline scripts are disabled in Elasticsearch` - ); - return asErr(FillPoolResult.Failed); - } else { - throw ex; - } - } - } else { - performance.mark('claimAvailableTasks.noAvailableWorkers'); - logger.debug( - `[Task Ownership]: Task Manager has skipped Claiming Ownership of available tasks at it has ran out Available Workers.` - ); - return asErr(FillPoolResult.NoAvailableWorkers); - } + }); } diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts index 75b9b2cdfa9779..57a4ab320367d4 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts @@ -52,6 +52,7 @@ describe('mark_available_tasks_as_claimed', () => { fieldUpdates, claimTasksById || [], definitions.getAllTypes(), + [], Array.from(definitions).reduce((accumulator, [type, { maxAttempts }]) => { return { ...accumulator, [type]: maxAttempts || defaultMaxAttempts }; }, {}) @@ -116,18 +117,23 @@ if (doc['task.runAt'].size()!=0) { seq_no_primary_term: true, script: { source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, + ctx.op = "noop"; + }`, lang: 'painless', params: { fieldUpdates: { @@ -135,7 +141,8 @@ if (doc['task.runAt'].size()!=0) { retryAt: claimOwnershipUntil, }, claimTasksById: [], - registeredTaskTypes: ['sampleTask', 'otherTask'], + claimableTaskTypes: ['sampleTask', 'otherTask'], + skippedTaskTypes: [], taskMaxAttempts: { sampleTask: 5, otherTask: 1, @@ -144,4 +151,76 @@ if (doc['task.runAt'].size()!=0) { }, }); }); + + describe(`script`, () => { + test('it supports claiming specific tasks by id', async () => { + const taskManagerId = '3478fg6-82374f6-83467gf5-384g6f'; + const claimOwnershipUntil = '2019-02-12T21:01:22.479Z'; + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + + const claimTasksById = [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ]; + + expect( + updateFieldsAndMarkAsFailed(fieldUpdates, claimTasksById, ['foo', 'bar'], [], { + foo: 5, + bar: 2, + }) + ).toMatchObject({ + source: ` + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { + ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; + } else { + ctx.op = "noop"; + }`, + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + taskMaxAttempts: { + foo: 5, + bar: 2, + }, + }, + }); + }); + + test('it marks the update as a noop if the type is skipped', async () => { + const taskManagerId = '3478fg6-82374f6-83467gf5-384g6f'; + const claimOwnershipUntil = '2019-02-12T21:01:22.479Z'; + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + + expect( + updateFieldsAndMarkAsFailed(fieldUpdates, [], ['foo', 'bar'], [], { + foo: 5, + bar: 2, + }).source + ).toMatch(/ctx.op = "noop"/); + }); + }); }); diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts index 067de5a92adb7b..8598980a4e2363 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts @@ -14,6 +14,8 @@ import { mustBeAllOf, MustCondition, BoolClauseWithAnyCondition, + ShouldCondition, + FilterCondition, } from './query_clauses'; export const TaskWithSchedule: ExistsFilter = { @@ -39,14 +41,26 @@ export function taskWithLessThanMaxAttempts( }; } -export function tasksClaimedByOwner(taskManagerId: string) { +export function tasksOfType(taskTypes: string[]): ShouldCondition { + return { + bool: { + should: [...taskTypes].map((type) => ({ term: { 'task.taskType': type } })), + }, + }; +} + +export function tasksClaimedByOwner( + taskManagerId: string, + ...taskFilters: Array | ShouldCondition> +) { return mustBeAllOf( { term: { 'task.ownerId': taskManagerId, }, }, - { term: { 'task.status': 'claiming' } } + { term: { 'task.status': 'claiming' } }, + ...taskFilters ); } @@ -107,27 +121,35 @@ export const updateFieldsAndMarkAsFailed = ( [field: string]: string | number | Date; }, claimTasksById: string[], - registeredTaskTypes: string[], + claimableTaskTypes: string[], + skippedTaskTypes: string[], taskMaxAttempts: { [field: string]: number } -): ScriptClause => ({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} +): ScriptClause => { + const markAsClaimingScript = `ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) + .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) + .join(' ')}`; + return { + source: ` + if (params.claimableTaskTypes.contains(ctx._source.task.taskType)) { + if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { + ${markAsClaimingScript} + } else { + ctx._source.task.status = "failed"; + } + } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { + ${markAsClaimingScript} + } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + ctx._source.task.status = "unrecognized"; } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById, - registeredTaskTypes, - taskMaxAttempts, - }, -}); + ctx.op = "noop"; + }`, + lang: 'painless', + params: { + fieldUpdates, + claimTasksById, + claimableTaskTypes, + skippedTaskTypes, + taskMaxAttempts, + }, + }; +}; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts new file mode 100644 index 00000000000000..38f02780c485e9 --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.mock.ts @@ -0,0 +1,33 @@ +/* + * 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 { Observable, Subject } from 'rxjs'; +import { TaskClaim } from '../task_events'; + +import { TaskClaiming } from './task_claiming'; + +interface TaskClaimingOptions { + maxAttempts?: number; + taskManagerId?: string; + events?: Observable; +} +export const taskClaimingMock = { + create({ + maxAttempts = 0, + taskManagerId = '', + events = new Subject(), + }: TaskClaimingOptions) { + const mocked = ({ + claimAvailableTasks: jest.fn(), + claimAvailableTasksIfCapacityIsAvailable: jest.fn(), + maxAttempts, + taskManagerId, + events, + } as unknown) as jest.Mocked; + return mocked; + }, +}; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts new file mode 100644 index 00000000000000..bd1171d7fd2f82 --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts @@ -0,0 +1,1516 @@ +/* + * 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 uuid from 'uuid'; +import { filter, take, toArray } from 'rxjs/operators'; +import { some, none } from 'fp-ts/lib/Option'; + +import { TaskStatus, ConcreteTaskInstance } from '../task'; +import { SearchOpts, StoreOpts, UpdateByQueryOpts, UpdateByQuerySearchOpts } from '../task_store'; +import { asTaskClaimEvent, ClaimTaskErr, TaskClaimErrorType, TaskEvent } from '../task_events'; +import { asOk, asErr } from '../lib/result_type'; +import { TaskTypeDictionary } from '../task_type_dictionary'; +import { BoolClauseWithAnyCondition, TermFilter } from '../queries/query_clauses'; +import { mockLogger } from '../test_utils'; +import { TaskClaiming, OwnershipClaimingOpts, TaskClaimingOpts } from './task_claiming'; +import { Observable } from 'rxjs'; +import { taskStoreMock } from '../task_store.mock'; + +const taskManagerLogger = mockLogger(); + +beforeEach(() => jest.resetAllMocks()); + +const mockedDate = new Date('2019-02-12T21:01:22.479Z'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).Date = class Date { + constructor() { + return mockedDate; + } + static now() { + return mockedDate.getTime(); + } +}; + +const taskDefinitions = new TaskTypeDictionary(taskManagerLogger); +taskDefinitions.registerTaskDefinitions({ + report: { + title: 'report', + createTaskRunner: jest.fn(), + }, + dernstraight: { + title: 'dernstraight', + createTaskRunner: jest.fn(), + }, + yawn: { + title: 'yawn', + createTaskRunner: jest.fn(), + }, +}); + +describe('TaskClaiming', () => { + test(`should log when a certain task type is skipped due to having a zero concurency configuration`, () => { + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToZero: { + title: 'limitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToZero: { + title: 'anotherLimitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + + new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore: taskStoreMock.create({ taskManagerId: '' }), + maxAttempts: 2, + getCapacity: () => 10, + }); + + expect(taskManagerLogger.info).toHaveBeenCalledTimes(1); + expect(taskManagerLogger.info.mock.calls[0][0]).toMatchInlineSnapshot( + `"Task Manager will never claim tasks of the following types as their \\"maxConcurrency\\" is set to 0: limitedToZero, anotherLimitedToZero"` + ); + }); + + describe('claimAvailableTasks', () => { + function initialiseTestClaiming({ + storeOpts = {}, + taskClaimingOpts = {}, + hits = [generateFakeTasks(1)], + versionConflicts = 2, + }: { + storeOpts: Partial; + taskClaimingOpts: Partial; + hits?: ConcreteTaskInstance[][]; + versionConflicts?: number; + }) { + const definitions = storeOpts.definitions ?? taskDefinitions; + const store = taskStoreMock.create({ taskManagerId: storeOpts.taskManagerId }); + store.convertToSavedObjectIds.mockImplementation((ids) => ids.map((id) => `task:${id}`)); + + if (hits.length === 1) { + store.fetch.mockResolvedValue({ docs: hits[0] }); + store.updateByQuery.mockResolvedValue({ + updated: hits[0].length, + version_conflicts: versionConflicts, + total: hits[0].length, + }); + } else { + for (const docs of hits) { + store.fetch.mockResolvedValueOnce({ docs }); + store.updateByQuery.mockResolvedValueOnce({ + updated: docs.length, + version_conflicts: versionConflicts, + total: docs.length, + }); + } + } + + const taskClaiming = new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore: store, + maxAttempts: taskClaimingOpts.maxAttempts ?? 2, + getCapacity: taskClaimingOpts.getCapacity ?? (() => 10), + ...taskClaimingOpts, + }); + + return { taskClaiming, store }; + } + + async function testClaimAvailableTasks({ + storeOpts = {}, + taskClaimingOpts = {}, + claimingOpts, + hits = [generateFakeTasks(1)], + versionConflicts = 2, + }: { + storeOpts: Partial; + taskClaimingOpts: Partial; + claimingOpts: Omit; + hits?: ConcreteTaskInstance[][]; + versionConflicts?: number; + }) { + const getCapacity = taskClaimingOpts.getCapacity ?? (() => 10); + const { taskClaiming, store } = initialiseTestClaiming({ + storeOpts, + taskClaimingOpts, + hits, + versionConflicts, + }); + + const results = await getAllAsPromise(taskClaiming.claimAvailableTasks(claimingOpts)); + + expect(store.updateByQuery.mock.calls[0][1]).toMatchObject({ + max_docs: getCapacity(), + }); + expect(store.fetch.mock.calls[0][0]).toMatchObject({ size: getCapacity() }); + return results.map((result, index) => ({ + result, + args: { + search: store.fetch.mock.calls[index][0] as SearchOpts & { + query: BoolClauseWithAnyCondition; + }, + updateByQuery: store.updateByQuery.mock.calls[index] as [ + UpdateByQuerySearchOpts, + UpdateByQueryOpts + ], + }, + })); + } + + test('it filters claimed tasks down by supported types, maxAttempts, status, and runAt', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + + const [ + { + args: { + updateByQuery: [{ query, sort }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + definitions, + }, + taskClaimingOpts: { + maxAttempts, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + }); + expect(query).toMatchObject({ + bool: { + must: [ + { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'task.status': 'idle' } }, + { range: { 'task.runAt': { lte: 'now' } } }, + ], + }, + }, + { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }, + { range: { 'task.retryAt': { lte: 'now' } } }, + ], + }, + }, + ], + }, + }, + ], + filter: [ + { + bool: { + must_not: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, + }, + }, + ], + }, + }, + ], + }, + }); + expect(sort).toMatchObject([ + { + _script: { + type: 'number', + order: 'asc', + script: { + lang: 'painless', + source: ` +if (doc['task.retryAt'].size()!=0) { + return doc['task.retryAt'].value.toInstant().toEpochMilli(); +} +if (doc['task.runAt'].size()!=0) { + return doc['task.runAt'].value.toInstant().toEpochMilli(); +} + `, + }, + }, + }, + ]); + }); + + test('it supports claiming specific tasks by id', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: new Date(Date.now()), + }; + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + const [ + { + args: { + updateByQuery: [{ query, script, sort }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + }, + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + pinned: { + ids: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + organic: { + bool: { + must: [ + { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'task.status': 'idle' } }, + { range: { 'task.runAt': { lte: 'now' } } }, + ], + }, + }, + { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }, + { range: { 'task.retryAt': { lte: 'now' } } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + ], + filter: [ + { + bool: { + must_not: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, + }, + }, + ], + }, + }, + ], + }, + }); + + expect(script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + taskMaxAttempts: { + bar: customMaxAttempts, + foo: maxAttempts, + }, + }, + }); + + expect(sort).toMatchObject([ + '_score', + { + _script: { + type: 'number', + order: 'asc', + script: { + lang: 'painless', + source: ` +if (doc['task.retryAt'].size()!=0) { + return doc['task.retryAt'].value.toInstant().toEpochMilli(); +} +if (doc['task.runAt'].size()!=0) { + return doc['task.runAt'].value.toInstant().toEpochMilli(); +} + `, + }, + }, + }, + ]); + }); + + test('it should claim in batches partitioned by maxConcurrency', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: new Date(Date.now()), + }; + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToZero: { + title: 'limitedToZero', + maxConcurrency: 0, + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + finalUnlimited: { + title: 'finalUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + const results = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + case 'anotherLimitedToOne': + return 1; + case 'limitedToTwo': + return 2; + default: + return 10; + } + }, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [ + '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + }, + }); + + expect(results.length).toEqual(4); + + expect(results[0].args.updateByQuery[1].max_docs).toEqual(10); + expect(results[0].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [ + 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', + 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', + ], + claimableTaskTypes: ['unlimited', 'anotherUnlimited', 'finalUnlimited'], + skippedTaskTypes: [ + 'limitedToZero', + 'limitedToOne', + 'anotherLimitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + unlimited: maxAttempts, + }, + }, + }); + + expect(results[1].args.updateByQuery[1].max_docs).toEqual(1); + expect(results[1].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['limitedToOne'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'anotherLimitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + limitedToOne: maxAttempts, + }, + }, + }); + + expect(results[2].args.updateByQuery[1].max_docs).toEqual(1); + expect(results[2].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['anotherLimitedToOne'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'limitedToOne', + 'limitedToTwo', + ], + taskMaxAttempts: { + anotherLimitedToOne: maxAttempts, + }, + }, + }); + + expect(results[3].args.updateByQuery[1].max_docs).toEqual(2); + expect(results[3].args.updateByQuery[0].script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['limitedToTwo'], + skippedTaskTypes: [ + 'unlimited', + 'limitedToZero', + 'anotherUnlimited', + 'finalUnlimited', + 'limitedToOne', + 'anotherLimitedToOne', + ], + taskMaxAttempts: { + limitedToTwo: maxAttempts, + }, + }, + }); + }); + + test('it should reduce the available capacity from batch to batch', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToFive: { + title: 'limitedToFive', + maxConcurrency: 5, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + const results = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToTwo': + return 2; + case 'limitedToFive': + return 5; + default: + return 10; + } + }, + }, + hits: [ + [ + // 7 returned by unlimited query + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + mockInstance({ + taskType: 'unlimited', + }), + ], + // 2 returned by limitedToFive query + [ + mockInstance({ + taskType: 'limitedToFive', + }), + mockInstance({ + taskType: 'limitedToFive', + }), + ], + // 1 reterned by limitedToTwo query + [ + mockInstance({ + taskType: 'limitedToTwo', + }), + ], + ], + claimingOpts: { + claimOwnershipUntil: new Date(), + claimTasksById: [], + }, + }); + + expect(results.length).toEqual(3); + + expect(results[0].args.updateByQuery[1].max_docs).toEqual(10); + + // only capacity for 3, even though 5 are allowed + expect(results[1].args.updateByQuery[1].max_docs).toEqual(3); + + // only capacity for 1, even though 2 are allowed + expect(results[2].args.updateByQuery[1].max_docs).toEqual(1); + }); + + test('it shuffles the types claimed in batches to ensure no type starves another', async () => { + const maxAttempts = _.random(2, 43); + const definitions = new TaskTypeDictionary(mockLogger()); + const taskManagerId = uuid.v1(); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + anotherUnlimited: { + title: 'anotherUnlimited', + createTaskRunner: jest.fn(), + }, + finalUnlimited: { + title: 'finalUnlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + limitedToTwo: { + title: 'limitedToTwo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + + const { taskClaiming, store } = initialiseTestClaiming({ + storeOpts: { + taskManagerId, + definitions, + }, + taskClaimingOpts: { + maxAttempts, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + case 'anotherLimitedToOne': + return 1; + case 'limitedToTwo': + return 2; + default: + return 10; + } + }, + }, + }); + + async function getUpdateByQueryScriptParams() { + return ( + await getAllAsPromise( + taskClaiming.claimAvailableTasks({ + claimOwnershipUntil: new Date(), + }) + ) + ).map( + (result, index) => + (store.updateByQuery.mock.calls[index][0] as { + query: BoolClauseWithAnyCondition; + size: number; + sort: string | string[]; + script: { + params: { + claimableTaskTypes: string[]; + }; + }; + }).script.params.claimableTaskTypes + ); + } + + const firstCycle = await getUpdateByQueryScriptParams(); + store.updateByQuery.mockClear(); + const secondCycle = await getUpdateByQueryScriptParams(); + + expect(firstCycle.length).toEqual(4); + expect(secondCycle.length).toEqual(4); + expect(firstCycle).not.toMatchObject(secondCycle); + }); + + test('it claims tasks by setting their ownerId, status and retryAt', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: claimOwnershipUntil, + }; + const [ + { + args: { + updateByQuery: [{ script }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + }); + expect(script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimableTaskTypes: ['report', 'dernstraight', 'yawn'], + skippedTaskTypes: [], + taskMaxAttempts: { + dernstraight: 2, + report: 2, + yawn: 2, + }, + }, + }); + }); + + test('it filters out running tasks', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + id: 'aaa', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + ]; + const [ + { + result: { docs }, + args: { + search: { query }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + term: { + 'task.ownerId': taskManagerId, + }, + }, + { term: { 'task.status': 'claiming' } }, + { + bool: { + should: [ + { + term: { + 'task.taskType': 'report', + }, + }, + { + term: { + 'task.taskType': 'dernstraight', + }, + }, + { + term: { + 'task.taskType': 'yawn', + }, + }, + ], + }, + }, + ], + }, + }); + + expect(docs).toMatchObject([ + { + attempts: 0, + id: 'aaa', + schedule: undefined, + params: { hello: 'world' }, + runAt, + scope: ['reporting'], + state: { baby: 'Henhen' }, + status: 'claiming', + taskType: 'foo', + user: 'jimbo', + ownerId: taskManagerId, + }, + ]); + }); + + test('it returns task objects', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + id: 'aaa', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + mockInstance({ + id: 'bbb', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }), + ]; + const [ + { + result: { docs }, + args: { + search: { query }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: {}, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + }); + + expect(query).toMatchObject({ + bool: { + must: [ + { + term: { + 'task.ownerId': taskManagerId, + }, + }, + { term: { 'task.status': 'claiming' } }, + { + bool: { + should: [ + { + term: { + 'task.taskType': 'report', + }, + }, + { + term: { + 'task.taskType': 'dernstraight', + }, + }, + { + term: { + 'task.taskType': 'yawn', + }, + }, + ], + }, + }, + ], + }, + }); + + expect(docs).toMatchObject([ + { + attempts: 0, + id: 'aaa', + schedule: undefined, + params: { hello: 'world' }, + runAt, + scope: ['reporting'], + state: { baby: 'Henhen' }, + status: 'claiming', + taskType: 'foo', + user: 'jimbo', + ownerId: taskManagerId, + }, + { + attempts: 2, + id: 'bbb', + schedule: { interval: '5m' }, + params: { shazm: 1 }, + runAt, + scope: ['reporting', 'ceo'], + state: { henry: 'The 8th' }, + status: 'claiming', + taskType: 'bar', + user: 'dabo', + ownerId: taskManagerId, + }, + ]); + }); + + test('it returns version_conflicts that do not include conflicts that were proceeded against', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + mockInstance({ + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }), + mockInstance({ + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }), + ]; + const maxDocs = 10; + const [ + { + result: { + stats: { tasksUpdated, tasksConflicted, tasksClaimed }, + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + taskManagerId, + }, + taskClaimingOpts: { getCapacity: () => maxDocs }, + claimingOpts: { + claimOwnershipUntil, + }, + hits: [tasks], + // assume there were 20 version conflists, but thanks to `conflicts="proceed"` + // we proceeded to claim tasks + versionConflicts: 20, + }); + + expect(tasksUpdated).toEqual(2); + // ensure we only count conflicts that *may* have counted against max_docs, no more than that + expect(tasksConflicted).toEqual(10 - tasksUpdated!); + expect(tasksClaimed).toEqual(2); + }); + }); + + describe('task events', () => { + function generateTasks(taskManagerId: string) { + const runAt = new Date(); + const tasks = [ + { + id: 'claimed-by-id', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + { + id: 'claimed-by-schedule', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Claiming, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + { + id: 'already-running', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: TaskStatus.Running, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ]; + + return { taskManagerId, runAt, tasks }; + } + + function instantiateStoreWithMockedApiResponses({ + taskManagerId = uuid.v4(), + definitions = taskDefinitions, + getCapacity = () => 10, + tasksClaimed, + }: Partial> & { + taskManagerId?: string; + tasksClaimed?: ConcreteTaskInstance[][]; + } = {}) { + const { runAt, tasks: generatedTasks } = generateTasks(taskManagerId); + const taskCycles = tasksClaimed ?? [generatedTasks]; + + const taskStore = taskStoreMock.create({ taskManagerId }); + taskStore.convertToSavedObjectIds.mockImplementation((ids) => ids.map((id) => `task:${id}`)); + for (const docs of taskCycles) { + taskStore.fetch.mockResolvedValueOnce({ docs }); + taskStore.updateByQuery.mockResolvedValueOnce({ + updated: docs.length, + version_conflicts: 0, + total: docs.length, + }); + } + + taskStore.fetch.mockResolvedValue({ docs: [] }); + taskStore.updateByQuery.mockResolvedValue({ + updated: 0, + version_conflicts: 0, + total: 0, + }); + + const taskClaiming = new TaskClaiming({ + logger: taskManagerLogger, + definitions, + taskStore, + maxAttempts: 2, + getCapacity, + }); + + return { taskManagerId, runAt, taskClaiming }; + } + + test('emits an event when a task is succesfully claimed by id', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'claimed-by-id' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-id', + asOk({ + id: 'claimed-by-id', + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: 'claiming' as TaskStatus, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }) + ) + ); + }); + + test('emits an event when a task is succesfully claimed by id by is rejected as it would exceed maxCapacity of its taskType', async () => { + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + unlimited: { + title: 'unlimited', + createTaskRunner: jest.fn(), + }, + limitedToOne: { + title: 'limitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + anotherLimitedToOne: { + title: 'anotherLimitedToOne', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + }); + + const taskManagerId = uuid.v4(); + const { runAt, taskClaiming } = instantiateStoreWithMockedApiResponses({ + taskManagerId, + definitions, + getCapacity: (type) => { + switch (type) { + case 'limitedToOne': + // return 0 as there's already a `limitedToOne` task running + return 0; + default: + return 10; + } + }, + tasksClaimed: [ + // find on first claim cycle + [ + { + id: 'claimed-by-id-limited-concurrency', + runAt: new Date(), + taskType: 'limitedToOne', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ], + // second cycle + [ + { + id: 'claimed-by-schedule-unlimited', + runAt: new Date(), + taskType: 'unlimited', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }, + ], + ], + }); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => + event.id === 'claimed-by-id-limited-concurrency' + ), + take(1) + ) + .toPromise(); + + const [firstCycleResult, secondCycleResult] = await getAllAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id-limited-concurrency'], + claimOwnershipUntil: new Date(), + }) + ); + + expect(firstCycleResult.stats.tasksClaimed).toEqual(0); + expect(firstCycleResult.stats.tasksRejected).toEqual(1); + expect(firstCycleResult.stats.tasksUpdated).toEqual(1); + + // values accumulate from cycle to cycle + expect(secondCycleResult.stats.tasksClaimed).toEqual(0); + expect(secondCycleResult.stats.tasksRejected).toEqual(1); + expect(secondCycleResult.stats.tasksUpdated).toEqual(1); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-id-limited-concurrency', + asErr({ + task: some({ + id: 'claimed-by-id-limited-concurrency', + runAt, + taskType: 'limitedToOne', + schedule: undefined, + attempts: 0, + status: 'claiming' as TaskStatus, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY, + }) + ) + ); + }); + + test('emits an event when a task is succesfully by scheduling', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => + event.id === 'claimed-by-schedule' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['claimed-by-id'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'claimed-by-schedule', + asOk({ + id: 'claimed-by-schedule', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'claiming' as TaskStatus, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }) + ) + ); + }); + + test('emits an event when the store fails to claim a required task by id', async () => { + const { taskManagerId, runAt, taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'already-running' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['already-running'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'already-running', + asErr({ + task: some({ + id: 'already-running', + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'running' as TaskStatus, + params: { shazm: 1 }, + state: { henry: 'The 8th' }, + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + }), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS, + }) + ) + ); + }); + + test('emits an event when the store fails to find a task which was required by id', async () => { + const { taskClaiming } = instantiateStoreWithMockedApiResponses(); + + const promise = taskClaiming.events + .pipe( + filter( + (event: TaskEvent) => event.id === 'unknown-task' + ), + take(1) + ) + .toPromise(); + + await getFirstAsPromise( + taskClaiming.claimAvailableTasks({ + claimTasksById: ['unknown-task'], + claimOwnershipUntil: new Date(), + }) + ); + + const event = await promise; + expect(event).toMatchObject( + asTaskClaimEvent( + 'unknown-task', + asErr({ + task: none, + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED, + }) + ) + ); + }); + }); +}); + +function generateFakeTasks(count: number = 1) { + return _.times(count, (index) => mockInstance({ id: `task:id-${index}` })); +} + +function mockInstance(instance: Partial = {}) { + return Object.assign( + { + id: uuid.v4(), + taskType: 'bar', + sequenceNumber: 32, + primaryTerm: 32, + runAt: new Date(), + scheduledAt: new Date(), + startedAt: null, + retryAt: null, + attempts: 0, + params: {}, + scope: ['reporting'], + state: {}, + status: 'idle', + user: 'example', + ownerId: null, + }, + instance + ); +} + +function getFirstAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.subscribe(resolve, reject); + }); +} +function getAllAsPromise(obs$: Observable): Promise { + return new Promise((resolve, reject) => { + obs$.pipe(toArray()).subscribe(resolve, reject); + }); +} diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.ts new file mode 100644 index 00000000000000..b4e11dbf81eb10 --- /dev/null +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.ts @@ -0,0 +1,488 @@ +/* + * 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. + */ + +/* + * This module contains helpers for managing the task manager storage layer. + */ +import apm from 'elastic-apm-node'; +import { Subject, Observable, from, of } from 'rxjs'; +import { map, mergeScan } from 'rxjs/operators'; +import { difference, partition, groupBy, mapValues, countBy, pick } from 'lodash'; +import { some, none } from 'fp-ts/lib/Option'; + +import { Logger } from '../../../../../src/core/server'; + +import { asOk, asErr, Result } from '../lib/result_type'; +import { ConcreteTaskInstance, TaskStatus } from '../task'; +import { + TaskClaim, + asTaskClaimEvent, + TaskClaimErrorType, + startTaskTimer, + TaskTiming, +} from '../task_events'; + +import { + asUpdateByQuery, + shouldBeOneOf, + mustBeAllOf, + filterDownBy, + asPinnedQuery, + matchesClauses, + SortOptions, +} from './query_clauses'; + +import { + updateFieldsAndMarkAsFailed, + IdleTaskWithExpiredRunAt, + InactiveTasks, + RunningOrClaimingTaskWithExpiredRetryAt, + SortByRunAtAndRetryAt, + tasksClaimedByOwner, + tasksOfType, +} from './mark_available_tasks_as_claimed'; +import { TaskTypeDictionary } from '../task_type_dictionary'; +import { + correctVersionConflictsForContinuation, + TaskStore, + UpdateByQueryResult, +} from '../task_store'; +import { FillPoolResult } from '../lib/fill_pool'; + +export interface TaskClaimingOpts { + logger: Logger; + definitions: TaskTypeDictionary; + taskStore: TaskStore; + maxAttempts: number; + getCapacity: (taskType?: string) => number; +} + +export interface OwnershipClaimingOpts { + claimOwnershipUntil: Date; + claimTasksById?: string[]; + size: number; + taskTypes: Set; +} +export type IncrementalOwnershipClaimingOpts = OwnershipClaimingOpts & { + precedingQueryResult: UpdateByQueryResult; +}; +export type IncrementalOwnershipClaimingReduction = ( + opts: IncrementalOwnershipClaimingOpts +) => Promise; + +export interface FetchResult { + docs: ConcreteTaskInstance[]; +} + +export interface ClaimOwnershipResult { + stats: { + tasksUpdated: number; + tasksConflicted: number; + tasksClaimed: number; + tasksRejected: number; + }; + docs: ConcreteTaskInstance[]; + timing?: TaskTiming; +} + +enum BatchConcurrency { + Unlimited, + Limited, +} + +type TaskClaimingBatches = Array; +interface TaskClaimingBatch { + concurrency: Concurrency; + tasksTypes: TaskType; +} +type UnlimitedBatch = TaskClaimingBatch>; +type LimitedBatch = TaskClaimingBatch; + +export class TaskClaiming { + public readonly errors$ = new Subject(); + public readonly maxAttempts: number; + + private definitions: TaskTypeDictionary; + private events$: Subject; + private taskStore: TaskStore; + private getCapacity: (taskType?: string) => number; + private logger: Logger; + private readonly taskClaimingBatchesByType: TaskClaimingBatches; + private readonly taskMaxAttempts: Record; + + /** + * Constructs a new TaskStore. + * @param {TaskClaimingOpts} opts + * @prop {number} maxAttempts - The maximum number of attempts before a task will be abandoned + * @prop {TaskDefinition} definition - The definition of the task being run + */ + constructor(opts: TaskClaimingOpts) { + this.definitions = opts.definitions; + this.maxAttempts = opts.maxAttempts; + this.taskStore = opts.taskStore; + this.getCapacity = opts.getCapacity; + this.logger = opts.logger; + this.taskClaimingBatchesByType = this.partitionIntoClaimingBatches(this.definitions); + this.taskMaxAttempts = Object.fromEntries(this.normalizeMaxAttempts(this.definitions)); + + this.events$ = new Subject(); + } + + private partitionIntoClaimingBatches(definitions: TaskTypeDictionary): TaskClaimingBatches { + const { + limitedConcurrency, + unlimitedConcurrency, + skippedTypes, + } = groupBy(definitions.getAllDefinitions(), (definition) => + definition.maxConcurrency + ? 'limitedConcurrency' + : definition.maxConcurrency === 0 + ? 'skippedTypes' + : 'unlimitedConcurrency' + ); + + if (skippedTypes?.length) { + this.logger.info( + `Task Manager will never claim tasks of the following types as their "maxConcurrency" is set to 0: ${skippedTypes + .map(({ type }) => type) + .join(', ')}` + ); + } + return [ + ...(unlimitedConcurrency + ? [asUnlimited(new Set(unlimitedConcurrency.map(({ type }) => type)))] + : []), + ...(limitedConcurrency ? limitedConcurrency.map(({ type }) => asLimited(type)) : []), + ]; + } + + private normalizeMaxAttempts(definitions: TaskTypeDictionary) { + return new Map( + [...definitions].map(([type, { maxAttempts }]) => [type, maxAttempts || this.maxAttempts]) + ); + } + + private claimingBatchIndex = 0; + private getClaimingBatches() { + // return all batches, starting at index and cycling back to where we began + const batch = [ + ...this.taskClaimingBatchesByType.slice(this.claimingBatchIndex), + ...this.taskClaimingBatchesByType.slice(0, this.claimingBatchIndex), + ]; + // shift claimingBatchIndex by one so that next cycle begins at the next index + this.claimingBatchIndex = (this.claimingBatchIndex + 1) % this.taskClaimingBatchesByType.length; + return batch; + } + + public get events(): Observable { + return this.events$; + } + + private emitEvents = (events: TaskClaim[]) => { + events.forEach((event) => this.events$.next(event)); + }; + + public claimAvailableTasksIfCapacityIsAvailable( + claimingOptions: Omit + ): Observable> { + if (this.getCapacity()) { + return this.claimAvailableTasks(claimingOptions).pipe( + map((claimResult) => asOk(claimResult)) + ); + } + this.logger.debug( + `[Task Ownership]: Task Manager has skipped Claiming Ownership of available tasks at it has ran out Available Workers.` + ); + return of(asErr(FillPoolResult.NoAvailableWorkers)); + } + + public claimAvailableTasks({ + claimOwnershipUntil, + claimTasksById = [], + }: Omit): Observable { + const initialCapacity = this.getCapacity(); + return from(this.getClaimingBatches()).pipe( + mergeScan( + (accumulatedResult, batch) => { + const stopTaskTimer = startTaskTimer(); + const capacity = Math.min( + initialCapacity - accumulatedResult.stats.tasksClaimed, + isLimited(batch) ? this.getCapacity(batch.tasksTypes) : this.getCapacity() + ); + // if we have no more capacity, short circuit here + if (capacity <= 0) { + return of(accumulatedResult); + } + return from( + this.executClaimAvailableTasks({ + claimOwnershipUntil, + claimTasksById: claimTasksById.splice(0, capacity), + size: capacity, + taskTypes: isLimited(batch) ? new Set([batch.tasksTypes]) : batch.tasksTypes, + }).then((result) => { + const { stats, docs } = accumulateClaimOwnershipResults(accumulatedResult, result); + stats.tasksConflicted = correctVersionConflictsForContinuation( + stats.tasksClaimed, + stats.tasksConflicted, + initialCapacity + ); + return { stats, docs, timing: stopTaskTimer() }; + }) + ); + }, + // initialise the accumulation with no results + accumulateClaimOwnershipResults(), + // only run one batch at a time + 1 + ) + ); + } + + private executClaimAvailableTasks = async ({ + claimOwnershipUntil, + claimTasksById = [], + size, + taskTypes, + }: OwnershipClaimingOpts): Promise => { + const claimTasksByIdWithRawIds = this.taskStore.convertToSavedObjectIds(claimTasksById); + const { + updated: tasksUpdated, + version_conflicts: tasksConflicted, + } = await this.markAvailableTasksAsClaimed({ + claimOwnershipUntil, + claimTasksById: claimTasksByIdWithRawIds, + size, + taskTypes, + }); + + const docs = + tasksUpdated > 0 + ? await this.sweepForClaimedTasks(claimTasksByIdWithRawIds, taskTypes, size) + : []; + + const [documentsReturnedById, documentsClaimedBySchedule] = partition(docs, (doc) => + claimTasksById.includes(doc.id) + ); + + const [documentsClaimedById, documentsRequestedButNotClaimed] = partition( + documentsReturnedById, + // we filter the schduled tasks down by status is 'claiming' in the esearch, + // but we do not apply this limitation on tasks claimed by ID so that we can + // provide more detailed error messages when we fail to claim them + (doc) => doc.status === TaskStatus.Claiming + ); + + // count how many tasks we've claimed by ID and validate we have capacity for them to run + const remainingCapacityOfClaimByIdByType = mapValues( + // This means we take the tasks that were claimed by their ID and count them by their type + countBy(documentsClaimedById, (doc) => doc.taskType), + (count, type) => this.getCapacity(type) - count + ); + + const [documentsClaimedByIdWithinCapacity, documentsClaimedByIdOutOfCapacity] = partition( + documentsClaimedById, + (doc) => { + // if we've exceeded capacity, we reject this task + if (remainingCapacityOfClaimByIdByType[doc.taskType] < 0) { + // as we're rejecting this task we can inc the count so that we know + // to keep the next one returned by ID of the same type + remainingCapacityOfClaimByIdByType[doc.taskType]++; + return false; + } + return true; + } + ); + + const documentsRequestedButNotReturned = difference( + claimTasksById, + documentsReturnedById.map((doc) => doc.id) + ); + + this.emitEvents([ + ...documentsClaimedByIdWithinCapacity.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), + ...documentsClaimedByIdOutOfCapacity.map((doc) => + asTaskClaimEvent( + doc.id, + asErr({ + task: some(doc), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY, + }) + ) + ), + ...documentsClaimedBySchedule.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), + ...documentsRequestedButNotClaimed.map((doc) => + asTaskClaimEvent( + doc.id, + asErr({ + task: some(doc), + errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS, + }) + ) + ), + ...documentsRequestedButNotReturned.map((id) => + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ), + ]); + + const stats = { + tasksUpdated, + tasksConflicted, + tasksRejected: documentsClaimedByIdOutOfCapacity.length, + tasksClaimed: documentsClaimedByIdWithinCapacity.length + documentsClaimedBySchedule.length, + }; + + if (docs.length !== stats.tasksClaimed + stats.tasksRejected) { + this.logger.warn( + `[Task Ownership error]: ${stats.tasksClaimed} tasks were claimed by Kibana, but ${ + docs.length + } task(s) were fetched (${docs.map((doc) => doc.id).join(', ')})` + ); + } + + return { + stats, + docs: [...documentsClaimedByIdWithinCapacity, ...documentsClaimedBySchedule], + }; + }; + + private async markAvailableTasksAsClaimed({ + claimOwnershipUntil, + claimTasksById, + size, + taskTypes, + }: OwnershipClaimingOpts): Promise { + const { taskTypesToSkip = [], taskTypesToClaim = [] } = groupBy( + this.definitions.getAllTypes(), + (type) => (taskTypes.has(type) ? 'taskTypesToClaim' : 'taskTypesToSkip') + ); + + const queryForScheduledTasks = mustBeAllOf( + // Either a task with idle status and runAt <= now or + // status running or claiming with a retryAt <= now. + shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) + ); + + // The documents should be sorted by runAt/retryAt, unless there are pinned + // tasks being queried, in which case we want to sort by score first, and then + // the runAt/retryAt. That way we'll get the pinned tasks first. Note that + // the score seems to favor newer documents rather than older documents, so + // if there are not pinned tasks being queried, we do NOT want to sort by score + // at all, just by runAt/retryAt. + const sort: SortOptions = [SortByRunAtAndRetryAt]; + if (claimTasksById && claimTasksById.length) { + sort.unshift('_score'); + } + + const apmTrans = apm.startTransaction(`taskManager markAvailableTasksAsClaimed`, 'taskManager'); + const result = await this.taskStore.updateByQuery( + asUpdateByQuery({ + query: matchesClauses( + claimTasksById && claimTasksById.length + ? mustBeAllOf(asPinnedQuery(claimTasksById, queryForScheduledTasks)) + : queryForScheduledTasks, + filterDownBy(InactiveTasks) + ), + update: updateFieldsAndMarkAsFailed( + { + ownerId: this.taskStore.taskManagerId, + retryAt: claimOwnershipUntil, + }, + claimTasksById || [], + taskTypesToClaim, + taskTypesToSkip, + pick(this.taskMaxAttempts, taskTypesToClaim) + ), + sort, + }), + { + max_docs: size, + } + ); + + if (apmTrans) apmTrans.end(); + return result; + } + + /** + * Fetches tasks from the index, which are owned by the current Kibana instance + */ + private async sweepForClaimedTasks( + claimTasksById: OwnershipClaimingOpts['claimTasksById'], + taskTypes: Set, + size: number + ): Promise { + const claimedTasksQuery = tasksClaimedByOwner( + this.taskStore.taskManagerId, + tasksOfType([...taskTypes]) + ); + const { docs } = await this.taskStore.fetch({ + query: + claimTasksById && claimTasksById.length + ? asPinnedQuery(claimTasksById, claimedTasksQuery) + : claimedTasksQuery, + size, + sort: SortByRunAtAndRetryAt, + seq_no_primary_term: true, + }); + + return docs; + } +} + +const emptyClaimOwnershipResult = () => { + return { + stats: { + tasksUpdated: 0, + tasksConflicted: 0, + tasksClaimed: 0, + tasksRejected: 0, + }, + docs: [], + }; +}; + +function accumulateClaimOwnershipResults( + prev: ClaimOwnershipResult = emptyClaimOwnershipResult(), + next?: ClaimOwnershipResult +) { + if (next) { + const { stats, docs, timing } = next; + const res = { + stats: { + tasksUpdated: stats.tasksUpdated + prev.stats.tasksUpdated, + tasksConflicted: stats.tasksConflicted + prev.stats.tasksConflicted, + tasksClaimed: stats.tasksClaimed + prev.stats.tasksClaimed, + tasksRejected: stats.tasksRejected + prev.stats.tasksRejected, + }, + docs, + timing, + }; + return res; + } + return prev; +} + +function isLimited( + batch: TaskClaimingBatch +): batch is LimitedBatch { + return batch.concurrency === BatchConcurrency.Limited; +} +function asLimited(tasksType: string): LimitedBatch { + return { + concurrency: BatchConcurrency.Limited, + tasksTypes: tasksType, + }; +} +function asUnlimited(tasksTypes: Set): UnlimitedBatch { + return { + concurrency: BatchConcurrency.Unlimited, + tasksTypes, + }; +} diff --git a/x-pack/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts index 04589d696427af..4b86943ff8eca2 100644 --- a/x-pack/plugins/task_manager/server/task.ts +++ b/x-pack/plugins/task_manager/server/task.ts @@ -127,6 +127,16 @@ export const taskDefinitionSchema = schema.object( min: 1, }) ), + /** + * The maximum number tasks of this type that can be run concurrently per Kibana instance. + * Setting this value will force Task Manager to poll for this task type seperatly from other task types + * which can add significant load to the ES cluster, so please use this configuration only when absolutly necesery. + */ + maxConcurrency: schema.maybe( + schema.number({ + min: 0, + }) + ), }, { validate({ timeout }) { diff --git a/x-pack/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts index d3fb68aa367c1b..aecf7c9a2b7e89 100644 --- a/x-pack/plugins/task_manager/server/task_events.ts +++ b/x-pack/plugins/task_manager/server/task_events.ts @@ -23,6 +23,12 @@ export enum TaskEventType { TASK_MANAGER_STAT = 'TASK_MANAGER_STAT', } +export enum TaskClaimErrorType { + CLAIMED_BY_ID_OUT_OF_CAPACITY = 'CLAIMED_BY_ID_OUT_OF_CAPACITY', + CLAIMED_BY_ID_NOT_RETURNED = 'CLAIMED_BY_ID_NOT_RETURNED', + CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS = 'CLAIMED_BY_ID_NOT_IN_CLAIMING_STATUS', +} + export interface TaskTiming { start: number; stop: number; @@ -47,14 +53,18 @@ export interface RanTask { export type ErroredTask = RanTask & { error: Error; }; +export interface ClaimTaskErr { + task: Option; + errorType: TaskClaimErrorType; +} export type TaskMarkRunning = TaskEvent; export type TaskRun = TaskEvent; -export type TaskClaim = TaskEvent>; +export type TaskClaim = TaskEvent; export type TaskRunRequest = TaskEvent; export type TaskPollingCycle = TaskEvent>; -export type TaskManagerStats = 'load' | 'pollingDelay'; +export type TaskManagerStats = 'load' | 'pollingDelay' | 'claimDuration'; export type TaskManagerStat = TaskEvent; export type OkResultOf = EventType extends TaskEvent @@ -92,7 +102,7 @@ export function asTaskRunEvent( export function asTaskClaimEvent( id: string, - event: Result>, + event: Result, timing?: TaskTiming ): TaskClaim { return { diff --git a/x-pack/plugins/task_manager/server/task_pool.test.ts b/x-pack/plugins/task_manager/server/task_pool.test.ts index 6f82c477dca9e2..05eb7bd1b43e10 100644 --- a/x-pack/plugins/task_manager/server/task_pool.test.ts +++ b/x-pack/plugins/task_manager/server/task_pool.test.ts @@ -15,6 +15,7 @@ import { asOk } from './lib/result_type'; import { SavedObjectsErrorHelpers } from '../../../../src/core/server'; import moment from 'moment'; import uuid from 'uuid'; +import { TaskRunningStage } from './task_running'; describe('TaskPool', () => { test('occupiedWorkers are a sum of running tasks', async () => { @@ -370,6 +371,7 @@ describe('TaskPool', () => { cancel: async () => undefined, markTaskAsRunning: jest.fn(async () => true), run: mockRun(), + stage: TaskRunningStage.PENDING, toString: () => `TaskType "shooooo"`, get expiration() { return new Date(); diff --git a/x-pack/plugins/task_manager/server/task_pool.ts b/x-pack/plugins/task_manager/server/task_pool.ts index e30f9ef3154b2a..14c0c4581a15bb 100644 --- a/x-pack/plugins/task_manager/server/task_pool.ts +++ b/x-pack/plugins/task_manager/server/task_pool.ts @@ -25,6 +25,8 @@ interface Opts { } export enum TaskPoolRunResult { + // This mean we have no Run Result becuse no tasks were Ran in this cycle + NoTaskWereRan = 'NoTaskWereRan', // This means we're running all the tasks we claimed RunningAllClaimedTasks = 'RunningAllClaimedTasks', // This means we're running all the tasks we claimed and we're at capacity @@ -40,7 +42,7 @@ const VERSION_CONFLICT_MESSAGE = 'Task has been claimed by another Kibana servic */ export class TaskPool { private maxWorkers: number = 0; - private running = new Set(); + private tasksInPool = new Map(); private logger: Logger; private load$ = new Subject(); @@ -68,7 +70,7 @@ export class TaskPool { * Gets how many workers are currently in use. */ public get occupiedWorkers() { - return this.running.size; + return this.tasksInPool.size; } /** @@ -93,6 +95,16 @@ export class TaskPool { return this.maxWorkers - this.occupiedWorkers; } + /** + * Gets how many workers are currently in use by type. + */ + public getOccupiedWorkersByType(type: string) { + return [...this.tasksInPool.values()].reduce( + (count, runningTask) => (runningTask.definition.type === type ? ++count : count), + 0 + ); + } + /** * Attempts to run the specified list of tasks. Returns true if it was able * to start every task in the list, false if there was not enough capacity @@ -106,9 +118,11 @@ export class TaskPool { if (tasksToRun.length) { performance.mark('attemptToRun_start'); await Promise.all( - tasksToRun.map( - async (taskRunner) => - await taskRunner + tasksToRun + .filter((taskRunner) => !this.tasksInPool.has(taskRunner.id)) + .map(async (taskRunner) => { + this.tasksInPool.set(taskRunner.id, taskRunner); + return taskRunner .markTaskAsRunning() .then((hasTaskBeenMarkAsRunning: boolean) => hasTaskBeenMarkAsRunning @@ -118,8 +132,8 @@ export class TaskPool { message: VERSION_CONFLICT_MESSAGE, }) ) - .catch((err) => this.handleFailureOfMarkAsRunning(taskRunner, err)) - ) + .catch((err) => this.handleFailureOfMarkAsRunning(taskRunner, err)); + }) ); performance.mark('attemptToRun_stop'); @@ -139,13 +153,12 @@ export class TaskPool { public cancelRunningTasks() { this.logger.debug('Cancelling running tasks.'); - for (const task of this.running) { + for (const task of this.tasksInPool.values()) { this.cancelTask(task); } } private handleMarkAsRunning(taskRunner: TaskRunner) { - this.running.add(taskRunner); taskRunner .run() .catch((err) => { @@ -161,26 +174,31 @@ export class TaskPool { this.logger.warn(errorLogLine); } }) - .then(() => this.running.delete(taskRunner)); + .then(() => this.tasksInPool.delete(taskRunner.id)); } private handleFailureOfMarkAsRunning(task: TaskRunner, err: Error) { + this.tasksInPool.delete(task.id); this.logger.error(`Failed to mark Task ${task.toString()} as running: ${err.message}`); } private cancelExpiredTasks() { - for (const task of this.running) { - if (task.isExpired) { + for (const taskRunner of this.tasksInPool.values()) { + if (taskRunner.isExpired) { this.logger.warn( - `Cancelling task ${task.toString()} as it expired at ${task.expiration.toISOString()}${ - task.startedAt + `Cancelling task ${taskRunner.toString()} as it expired at ${taskRunner.expiration.toISOString()}${ + taskRunner.startedAt ? ` after running for ${durationAsString( - moment.duration(moment(new Date()).utc().diff(task.startedAt)) + moment.duration(moment(new Date()).utc().diff(taskRunner.startedAt)) )}` : `` - }${task.definition.timeout ? ` (with timeout set at ${task.definition.timeout})` : ``}.` + }${ + taskRunner.definition.timeout + ? ` (with timeout set at ${taskRunner.definition.timeout})` + : `` + }.` ); - this.cancelTask(task); + this.cancelTask(taskRunner); } } } @@ -188,7 +206,7 @@ export class TaskPool { private async cancelTask(task: TaskRunner) { try { this.logger.debug(`Cancelling task ${task.toString()}.`); - this.running.delete(task); + this.tasksInPool.delete(task.id); await task.cancel(); } catch (err) { this.logger.error(`Failed to cancel task ${task.toString()}: ${err}`); diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index dff8c1f24de0ae..5a36d6affe686c 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import sinon from 'sinon'; import { secondsFromNow } from '../lib/intervals'; import { asOk, asErr } from '../lib/result_type'; -import { TaskManagerRunner, TaskRunResult } from '../task_running'; +import { TaskManagerRunner, TaskRunningStage, TaskRunResult } from '../task_running'; import { TaskEvent, asTaskRunEvent, asTaskMarkRunningEvent, TaskRun } from '../task_events'; import { ConcreteTaskInstance, TaskStatus } from '../task'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; @@ -17,6 +17,7 @@ import moment from 'moment'; import { TaskDefinitionRegistry, TaskTypeDictionary } from '../task_type_dictionary'; import { mockLogger } from '../test_utils'; import { throwUnrecoverableError } from './errors'; +import { taskStoreMock } from '../task_store.mock'; const minutesFromNow = (mins: number): Date => secondsFromNow(mins * 60); @@ -29,980 +30,834 @@ beforeAll(() => { afterAll(() => fakeTimer.restore()); describe('TaskManagerRunner', () => { - test('provides details about the task that is running', () => { - const { runner } = testOpts({ - instance: { - id: 'foo', - taskType: 'bar', - }, - }); + const pendingStageSetup = (opts: TestOpts) => testOpts(TaskRunningStage.PENDING, opts); + const readyToRunStageSetup = (opts: TestOpts) => testOpts(TaskRunningStage.READY_TO_RUN, opts); - expect(runner.id).toEqual('foo'); - expect(runner.taskType).toEqual('bar'); - expect(runner.toString()).toEqual('bar "foo"'); - }); - - test('queues a reattempt if the task fails', async () => { - const initialAttempts = _.random(0, 2); - const id = Date.now().toString(); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - params: { a: 'b' }, - state: { hey: 'there' }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - throw new Error('Dangit!'); - }, - }), + describe('Pending Stage', () => { + test('provides details about the task that is running', async () => { + const { runner } = await pendingStageSetup({ + instance: { + id: 'foo', + taskType: 'bar', }, - }, + }); + + expect(runner.id).toEqual('foo'); + expect(runner.taskType).toEqual('bar'); + expect(runner.toString()).toEqual('bar "foo"'); }); - await runner.run(); + test('calculates retryAt by schedule when running a recurring task', async () => { + const intervalMinutes = 10; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalMinutes}m`, + }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.id).toEqual(id); - expect(instance.runAt.getTime()).toEqual(minutesFromNow(initialAttempts * 5).getTime()); - expect(instance.params).toEqual({ a: 'b' }); - expect(instance.state).toEqual({ hey: 'there' }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('reschedules tasks that have an schedule', async () => { - const { runner, store } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + instance.startedAt!.getTime() + intervalMinutes * 60 * 1000 + ); }); - await runner.run(); + test('calculates retryAt by default timout when it exceeds the schedule of a recurring task', async () => { + const intervalSeconds = 20; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalSeconds}s`, + }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.runAt.getTime()).toBeGreaterThan(minutesFromNow(9).getTime()); - expect(instance.runAt.getTime()).toBeLessThanOrEqual(minutesFromNow(10).getTime()); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('expiration returns time after which timeout will have elapsed from start', async () => { - const now = moment(); - const { runner } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: now.toDate(), - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `1m`, - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual(instance.startedAt!.getTime() + 5 * 60 * 1000); }); - await runner.run(); - - expect(runner.isExpired).toBe(false); - expect(runner.expiration).toEqual(now.add(1, 'm').toDate()); - }); - - test('runDuration returns duration which has elapsed since start', async () => { - const now = moment().subtract(30, 's').toDate(); - const { runner } = testOpts({ - instance: { - schedule: { interval: '10m' }, - status: TaskStatus.Running, - startedAt: now, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `1m`, - createTaskRunner: () => ({ - async run() { - return { state: {} }; - }, - }), + test('calculates retryAt by timeout if it exceeds the schedule when running a recurring task', async () => { + const timeoutMinutes = 1; + const intervalSeconds = 20; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { + interval: `${intervalSeconds}s`, + }, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - expect(runner.isExpired).toBe(false); - expect(runner.startedAt).toEqual(now); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('reschedules tasks that return a runAt', async () => { - const runAt = minutesFromNow(_.random(1, 10)); - const { runner, store } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { runAt, state: {} }; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + instance.startedAt!.getTime() + timeoutMinutes * 60 * 1000 + ); }); - await runner.run(); - - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); - - test('reschedules tasks that return a schedule', async () => { - const runAt = minutesFromNow(1); - const schedule = { - interval: '1m', - }; - const { runner, store } = testOpts({ - instance: { - status: TaskStatus.Running, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { schedule, state: {} }; - }, - }), + test('sets startedAt, status, attempts and retryAt when claiming a task', async () => { + const timeoutMinutes = 1; + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(0, 2); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test(`doesn't reschedule recurring tasks that throw an unrecoverable error`, async () => { - const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, store, instance: originalInstance } = testOpts({ - onTaskEvent, - instance: { id, status: TaskStatus.Running, startedAt: new Date() }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - throwUnrecoverableError(error); - }, - }), - }, - }, + expect(instance.attempts).toEqual(initialAttempts + 1); + expect(instance.status).toBe('running'); + expect(instance.startedAt!.getTime()).toEqual(Date.now()); + expect(instance.retryAt!.getTime()).toEqual( + minutesFromNow((initialAttempts + 1) * 5).getTime() + timeoutMinutes * 60 * 1000 + ); }); - await runner.run(); - - const instance = store.update.args[0][0]; - expect(instance.status).toBe('failed'); - - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent( + test('uses getRetry (returning date) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { id, - asErr({ - error, - task: originalInstance, - result: TaskRunResult.Failed, - }) - ) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); - }); - - test('tasks that return runAt override the schedule', async () => { - const runAt = minutesFromNow(_.random(5)); - const { runner, store } = testOpts({ - instance: { - schedule: { interval: '20m' }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return { runAt, state: {} }; - }, - }), + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWithMatch(store.update, { runAt }); - }); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - test('removes non-recurring tasks after they complete', async () => { - const id = _.random(1, 20).toString(); - const { runner, store } = testOpts({ - instance: { - id, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - return undefined; - }, - }), - }, - }, + expect(instance.retryAt!.getTime()).toEqual( + new Date(nextRetry.getTime() + timeoutMinutes * 60 * 1000).getTime() + ); }); - await runner.run(); - - sinon.assert.calledOnce(store.remove); - sinon.assert.calledWith(store.remove, id); - }); - - test('cancel cancels the task runner, if it is cancellable', async () => { - let wasCancelled = false; - const { runner, logger } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - async run() { - const promise = new Promise((r) => setTimeout(r, 1000)); - fakeTimer.tick(1000); - await promise; - }, - async cancel() { - wasCancelled = true; - }, - }), + test('it returns false when markTaskAsRunning fails due to VERSION_CONFLICT_STATUS', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - const promise = runner.run(); - await Promise.resolve(); - await runner.cancel(); - await promise; + store.update.mockRejectedValue( + SavedObjectsErrorHelpers.decorateConflictError(new Error('repo error')) + ); - expect(wasCancelled).toBeTruthy(); - expect(logger.warn).not.toHaveBeenCalled(); - }); + expect(await runner.markTaskAsRunning()).toEqual(false); + }); - test('debug logs if cancel is called on a non-cancellable task', async () => { - const { runner, logger } = testOpts({ - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('it throw when markTaskAsRunning fails for unexpected reasons', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - const promise = runner.run(); - await runner.cancel(); - await promise; + store.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id') + ); - expect(logger.debug).toHaveBeenCalledWith(`The task bar "foo" is not cancellable.`); - }); + return expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: Saved object [type/id] not found]` + ); + }); - test('sets startedAt, status, attempts and retryAt when claiming a task', async () => { - const timeoutMinutes = 1; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test(`it tries to increment a task's attempts when markTaskAsRunning fails for unexpected reasons`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce(SavedObjectsErrorHelpers.createBadRequestError('type')); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: type: Bad Request]` + ); - expect(instance.attempts).toEqual(initialAttempts + 1); - expect(instance.status).toBe('running'); - expect(instance.startedAt.getTime()).toEqual(Date.now()); - expect(instance.retryAt.getTime()).toEqual( - minutesFromNow((initialAttempts + 1) * 5).getTime() + timeoutMinutes * 60 * 1000 - ); - }); + expect(store.update).toHaveBeenCalledWith({ + ...mockInstance({ + id, + attempts: initialAttempts + 1, + schedule: undefined, + }), + status: TaskStatus.Idle, + startedAt: null, + retryAt: null, + ownerId: null, + }); + }); - test('calculates retryAt by schedule when running a recurring task', async () => { - const intervalMinutes = 10; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalMinutes}m`, + test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails for version conflict`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createConflictError('type', 'id') + ); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).resolves.toMatchInlineSnapshot(`false`); - expect(instance.retryAt.getTime()).toEqual( - instance.startedAt.getTime() + intervalMinutes * 60 * 1000 - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + }); - test('calculates retryAt by default timout when it exceeds the schedule of a recurring task', async () => { - const intervalSeconds = 20; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalSeconds}s`, + test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails due to Saved Object not being found`, async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(nextRetry); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + store.update.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id') + ); + store.update.mockResolvedValueOnce( + mockInstance({ + id, + attempts: initialAttempts, + schedule: undefined, + }) + ); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( + `[Error: Saved object [type/id] not found]` + ); - expect(instance.retryAt.getTime()).toEqual(instance.startedAt.getTime() + 5 * 60 * 1000); - }); + expect(store.update).toHaveBeenCalledTimes(1); + }); - test('calculates retryAt by timeout if it exceeds the schedule when running a recurring task', async () => { - const timeoutMinutes = 1; - const intervalSeconds = 20; - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(0, 2); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { - interval: `${intervalSeconds}s`, + test('uses getRetry (returning true) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(true); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - createTaskRunner: () => ({ - run: async () => undefined, - }), + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, }, - }, - }); + }); - await runner.markTaskAsRunning(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - expect(instance.retryAt.getTime()).toEqual( - instance.startedAt.getTime() + timeoutMinutes * 60 * 1000 - ); - }); + const attemptDelay = (initialAttempts + 1) * 5 * 60 * 1000; + const timeoutDelay = timeoutMinutes * 60 * 1000; + expect(instance.retryAt!.getTime()).toEqual( + new Date(Date.now() + attemptDelay + timeoutDelay).getTime() + ); + }); - test('uses getRetry function (returning date) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(nextRetry); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), + test('uses getRetry (returning false) to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(false); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts + 1); + const instance = store.update.mock.calls[0][0]; - expect(instance.runAt.getTime()).toEqual(nextRetry.getTime()); - }); + expect(instance.retryAt!).toBeNull(); + expect(instance.status).toBe('running'); + }); - test('uses getRetry function (returning true) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(true); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), + test('bypasses getRetry (returning false) of a recurring task to set retryAt when defined', async () => { + const id = _.random(1, 20).toString(); + const initialAttempts = _.random(1, 3); + const timeoutMinutes = 1; + const getRetryStub = sinon.stub().returns(false); + const { runner, store } = await pendingStageSetup({ + instance: { + id, + attempts: initialAttempts, + schedule: { interval: '1m' }, + startedAt: new Date(), }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + timeout: `${timeoutMinutes}m`, + getRetry: getRetryStub, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - await runner.run(); + await runner.markTaskAsRunning(); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.notCalled(getRetryStub); + const instance = store.update.mock.calls[0][0]; - const expectedRunAt = new Date(Date.now() + initialAttempts * 5 * 60 * 1000); - expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); - }); + const timeoutDelay = timeoutMinutes * 60 * 1000; + expect(instance.retryAt!.getTime()).toEqual(new Date(Date.now() + timeoutDelay).getTime()); + }); - test('uses getRetry function (returning false) on error when defined', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(false); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; + describe('TaskEvents', () => { + test('emits TaskEvent when a task is marked as running', async () => { + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, instance, store } = await pendingStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), }, - }), - }, - }, - }); + }, + }); - await runner.run(); + store.update.mockResolvedValueOnce(instance); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts, error); - const instance = store.update.args[0][0]; + await runner.markTaskAsRunning(); - expect(instance.status).toBe('failed'); - }); + expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asOk(instance))); + }); - test('bypasses getRetry function (returning false) on error of a recurring task', async () => { - const initialAttempts = _.random(1, 3); - const id = Date.now().toString(); - const getRetryStub = sinon.stub().returns(false); - const error = new Error('Dangit!'); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: '1m' }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - getRetry: getRetryStub, - createTaskRunner: () => ({ - async run() { - throw error; - }, - }), - }, - }, - }); + test('emits TaskEvent when a task fails to be marked as running', async () => { + expect.assertions(2); - await runner.run(); + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, store } = await pendingStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + run: async () => undefined, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.notCalled(getRetryStub); - const instance = store.update.args[0][0]; + store.update.mockRejectedValueOnce(new Error('cant mark as running')); - const nextIntervalDelay = 60000; // 1m - const expectedRunAt = new Date(Date.now() + nextIntervalDelay); - expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); + try { + await runner.markTaskAsRunning(); + } catch (err) { + expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asErr(err))); + } + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + }); }); - test('uses getRetry (returning date) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + describe('Ready To Run Stage', () => { + test('queues a reattempt if the task fails', async () => { + const initialAttempts = _.random(0, 2); + const id = Date.now().toString(); + const { runner, store } = await readyToRunStageSetup({ + instance: { + id, + attempts: initialAttempts, + params: { a: 'b' }, + state: { hey: 'there' }, }, - }, - }); - - await runner.markTaskAsRunning(); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw new Error('Dangit!'); + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + await runner.run(); - expect(instance.retryAt.getTime()).toEqual( - new Date(nextRetry.getTime() + timeoutMinutes * 60 * 1000).getTime() - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test('it returns false when markTaskAsRunning fails due to VERSION_CONFLICT_STATUS', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(instance.id).toEqual(id); + expect(instance.runAt.getTime()).toEqual(minutesFromNow(initialAttempts * 5).getTime()); + expect(instance.params).toEqual({ a: 'b' }); + expect(instance.state).toEqual({ hey: 'there' }); }); - store.update = sinon - .stub() - .throws(SavedObjectsErrorHelpers.decorateConflictError(new Error('repo error'))); - - expect(await runner.markTaskAsRunning()).toEqual(false); - }); - - test('it throw when markTaskAsRunning fails for unexpected reasons', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('reschedules tasks that have an schedule', async () => { + const { runner, store } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: new Date(), }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - store.update = sinon - .stub() - .throws(SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id')); + await runner.run(); - return expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: Saved object [type/id] not found]` - ); - }); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; - test(`it tries to increment a task's attempts when markTaskAsRunning fails for unexpected reasons`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(instance.runAt.getTime()).toBeGreaterThan(minutesFromNow(9).getTime()); + expect(instance.runAt.getTime()).toBeLessThanOrEqual(minutesFromNow(10).getTime()); }); - store.update = sinon.stub(); - store.update.onFirstCall().throws(SavedObjectsErrorHelpers.createBadRequestError('type')); - store.update.onSecondCall().resolves(); + test('expiration returns time after which timeout will have elapsed from start', async () => { + const now = moment(); + const { runner } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: now.toDate(), + }, + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: type: Bad Request]` - ); + await runner.run(); - sinon.assert.calledWith(store.update, { - ...mockInstance({ - id, - attempts: initialAttempts + 1, - schedule: undefined, - }), - status: TaskStatus.Idle, - startedAt: null, - retryAt: null, - ownerId: null, + expect(runner.isExpired).toBe(false); + expect(runner.expiration).toEqual(now.add(1, 'm').toDate()); }); - }); - test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails for version conflict`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), + test('runDuration returns duration which has elapsed since start', async () => { + const now = moment().subtract(30, 's').toDate(); + const { runner } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '10m' }, + status: TaskStatus.Running, + startedAt: now, }, - }, - }); - - store.update = sinon.stub(); - store.update.onFirstCall().throws(SavedObjectsErrorHelpers.createConflictError('type', 'id')); - store.update.onSecondCall().resolves(); - - await expect(runner.markTaskAsRunning()).resolves.toMatchInlineSnapshot(`false`); + definitions: { + bar: { + title: 'Bar!', + timeout: `1m`, + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - }); + await runner.run(); - test(`it doesnt try to increment a task's attempts when markTaskAsRunning fails due to Saved Object not being found`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(nextRetry); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(runner.isExpired).toBe(false); + expect(runner.startedAt).toEqual(now); }); - store.update = sinon.stub(); - store.update - .onFirstCall() - .throws(SavedObjectsErrorHelpers.createGenericNotFoundError('type', 'id')); - store.update.onSecondCall().resolves(); - - await expect(runner.markTaskAsRunning()).rejects.toMatchInlineSnapshot( - `[Error: Saved object [type/id] not found]` - ); + test('reschedules tasks that return a runAt', async () => { + const runAt = minutesFromNow(_.random(1, 10)); + const { runner, store } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - }); + await runner.run(); - test('uses getRetry (returning true) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(true); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); }); - await runner.markTaskAsRunning(); - - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + test('reschedules tasks that return a schedule', async () => { + const runAt = minutesFromNow(1); + const schedule = { + interval: '1m', + }; + const { runner, store } = await readyToRunStageSetup({ + instance: { + status: TaskStatus.Running, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { schedule, state: {} }; + }, + }), + }, + }, + }); - const attemptDelay = (initialAttempts + 1) * 5 * 60 * 1000; - const timeoutDelay = timeoutMinutes * 60 * 1000; - expect(instance.retryAt.getTime()).toEqual( - new Date(Date.now() + attemptDelay + timeoutDelay).getTime() - ); - }); + await runner.run(); - test('uses getRetry (returning false) to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(false); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); }); - await runner.markTaskAsRunning(); + test(`doesn't reschedule recurring tasks that throw an unrecoverable error`, async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, store, instance: originalInstance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { id, status: TaskStatus.Running, startedAt: new Date() }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throwUnrecoverableError(error); + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.calledWith(getRetryStub, initialAttempts + 1); - const instance = store.update.args[0][0]; + await runner.run(); - expect(instance.retryAt).toBeNull(); - expect(instance.status).toBe('running'); - }); + const instance = store.update.mock.calls[0][0]; + expect(instance.status).toBe('failed'); - test('bypasses getRetry (returning false) of a recurring task to set retryAt when defined', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = _.random(1, 3); - const timeoutMinutes = 1; - const getRetryStub = sinon.stub().returns(false); - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: '1m' }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - timeout: `${timeoutMinutes}m`, - getRetry: getRetryStub, - createTaskRunner: () => ({ - run: async () => undefined, - }), - }, - }, + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ + error, + task: originalInstance, + result: TaskRunResult.Failed, + }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); }); - await runner.markTaskAsRunning(); + test('tasks that return runAt override the schedule', async () => { + const runAt = minutesFromNow(_.random(5)); + const { runner, store } = await readyToRunStageSetup({ + instance: { + schedule: { interval: '20m' }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); - sinon.assert.calledOnce(store.update); - sinon.assert.notCalled(getRetryStub); - const instance = store.update.args[0][0]; + await runner.run(); - const timeoutDelay = timeoutMinutes * 60 * 1000; - expect(instance.retryAt.getTime()).toEqual(new Date(Date.now() + timeoutDelay).getTime()); - }); + expect(store.update).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledWith(expect.objectContaining({ runAt })); + }); - test('Fails non-recurring task when maxAttempts reached', async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = 3; - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: undefined, - }, - definitions: { - bar: { - title: 'Bar!', - maxAttempts: 3, - createTaskRunner: () => ({ - run: async () => { - throw new Error(); - }, - }), + test('removes non-recurring tasks after they complete', async () => { + const id = _.random(1, 20).toString(); + const { runner, store } = await readyToRunStageSetup({ + instance: { + id, + schedule: undefined, }, - }, - }); + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return undefined; + }, + }), + }, + }, + }); - await runner.run(); + await runner.run(); - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; - expect(instance.attempts).toEqual(3); - expect(instance.status).toEqual('failed'); - expect(instance.retryAt).toBeNull(); - expect(instance.runAt.getTime()).toBeLessThanOrEqual(Date.now()); - }); + expect(store.remove).toHaveBeenCalledTimes(1); + expect(store.remove).toHaveBeenCalledWith(id); + }); - test(`Doesn't fail recurring tasks when maxAttempts reached`, async () => { - const id = _.random(1, 20).toString(); - const initialAttempts = 3; - const intervalSeconds = 10; - const { runner, store } = testOpts({ - instance: { - id, - attempts: initialAttempts, - schedule: { interval: `${intervalSeconds}s` }, - startedAt: new Date(), - }, - definitions: { - bar: { - title: 'Bar!', - maxAttempts: 3, - createTaskRunner: () => ({ - run: async () => { - throw new Error(); - }, - }), + test('cancel cancels the task runner, if it is cancellable', async () => { + let wasCancelled = false; + const { runner, logger } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + const promise = new Promise((r) => setTimeout(r, 1000)); + fakeTimer.tick(1000); + await promise; + }, + async cancel() { + wasCancelled = true; + }, + }), + }, }, - }, - }); + }); - await runner.run(); + const promise = runner.run(); + await Promise.resolve(); + await runner.cancel(); + await promise; - sinon.assert.calledOnce(store.update); - const instance = store.update.args[0][0]; - expect(instance.attempts).toEqual(3); - expect(instance.status).toEqual('idle'); - expect(instance.runAt.getTime()).toEqual( - new Date(Date.now() + intervalSeconds * 1000).getTime() - ); - }); + expect(wasCancelled).toBeTruthy(); + expect(logger.warn).not.toHaveBeenCalled(); + }); - describe('TaskEvents', () => { - test('emits TaskEvent when a task is marked as running', async () => { - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, instance, store } = testOpts({ - onTaskEvent, - instance: { - id, - }, + test('debug logs if cancel is called on a non-cancellable task', async () => { + const { runner, logger } = await readyToRunStageSetup({ definitions: { bar: { title: 'Bar!', - timeout: `1m`, createTaskRunner: () => ({ run: async () => undefined, }), @@ -1010,58 +865,63 @@ describe('TaskManagerRunner', () => { }, }); - store.update.returns(instance); + const promise = runner.run(); + await runner.cancel(); + await promise; - await runner.markTaskAsRunning(); - - expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asOk(instance))); + expect(logger.debug).toHaveBeenCalledWith(`The task bar "foo" is not cancellable.`); }); - test('emits TaskEvent when a task fails to be marked as running', async () => { - expect.assertions(2); - - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, store } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning date) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const nextRetry = new Date(Date.now() + _.random(15, 100) * 1000); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(nextRetry); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', - timeout: `1m`, + getRetry: getRetryStub, createTaskRunner: () => ({ - run: async () => undefined, + async run() { + throw error; + }, }), }, }, }); - store.update.throws(new Error('cant mark as running')); + await runner.run(); - try { - await runner.markTaskAsRunning(); - } catch (err) { - expect(onTaskEvent).toHaveBeenCalledWith(asTaskMarkRunningEvent(id, asErr(err))); - } - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + expect(instance.runAt.getTime()).toEqual(nextRetry.getTime()); }); - test('emits TaskEvent when a task is run successfully', async () => { - const id = _.random(1, 20).toString(); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning true) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(true); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { - return { state: {} }; + throw error; }, }), }, @@ -1070,27 +930,31 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) - ); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + const expectedRunAt = new Date(Date.now() + initialAttempts * 5 * 60 * 1000); + expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); }); - test('emits TaskEvent when a recurring task is run successfully', async () => { - const id = _.random(1, 20).toString(); - const runAt = minutesFromNow(_.random(5)); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + test('uses getRetry function (returning false) on error when defined', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(false); + const error = new Error('Dangit!'); + const { runner, store } = await readyToRunStageSetup({ instance: { id, - schedule: { interval: '1m' }, + attempts: initialAttempts, }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { - return { runAt, state: {} }; + throw error; }, }), }, @@ -1099,23 +963,29 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) - ); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.calledWith(getRetryStub, initialAttempts, error); + const instance = store.update.mock.calls[0][0]; + + expect(instance.status).toBe('failed'); }); - test('emits TaskEvent when a task run throws an error', async () => { - const id = _.random(1, 20).toString(); + test('bypasses getRetry function (returning false) on error of a recurring task', async () => { + const initialAttempts = _.random(1, 3); + const id = Date.now().toString(); + const getRetryStub = sinon.stub().returns(false); const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, + schedule: { interval: '1m' }, + startedAt: new Date(), }, definitions: { bar: { title: 'Bar!', + getRetry: getRetryStub, createTaskRunner: () => ({ async run() { throw error; @@ -1124,33 +994,34 @@ describe('TaskManagerRunner', () => { }, }, }); + await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent(id, asErr({ error, task: instance, result: TaskRunResult.RetryScheduled })) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + sinon.assert.notCalled(getRetryStub); + const instance = store.update.mock.calls[0][0]; + + const nextIntervalDelay = 60000; // 1m + const expectedRunAt = new Date(Date.now() + nextIntervalDelay); + expect(instance.runAt.getTime()).toEqual(expectedRunAt.getTime()); }); - test('emits TaskEvent when a task run returns an error', async () => { + test('Fails non-recurring task when maxAttempts reached', async () => { const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, instance } = testOpts({ - onTaskEvent, + const initialAttempts = 3; + const { runner, store } = await readyToRunStageSetup({ instance: { id, - schedule: { interval: '1m' }, - startedAt: new Date(), + attempts: initialAttempts, + schedule: undefined, }, definitions: { bar: { title: 'Bar!', + maxAttempts: 3, createTaskRunner: () => ({ - async run() { - return { error, state: {} }; + run: async () => { + throw new Error(); }, }), }, @@ -1159,31 +1030,32 @@ describe('TaskManagerRunner', () => { await runner.run(); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent(id, asErr({ error, task: instance, result: TaskRunResult.RetryScheduled })) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; + expect(instance.attempts).toEqual(3); + expect(instance.status).toEqual('failed'); + expect(instance.retryAt!).toBeNull(); + expect(instance.runAt.getTime()).toBeLessThanOrEqual(Date.now()); }); - test('emits TaskEvent when a task returns an error and is marked as failed', async () => { + test(`Doesn't fail recurring tasks when maxAttempts reached`, async () => { const id = _.random(1, 20).toString(); - const error = new Error('Dangit!'); - const onTaskEvent = jest.fn(); - const { runner, store, instance: originalInstance } = testOpts({ - onTaskEvent, + const initialAttempts = 3; + const intervalSeconds = 10; + const { runner, store } = await readyToRunStageSetup({ instance: { id, + attempts: initialAttempts, + schedule: { interval: `${intervalSeconds}s` }, startedAt: new Date(), }, definitions: { bar: { title: 'Bar!', - getRetry: () => false, + maxAttempts: 3, createTaskRunner: () => ({ - async run() { - return { error, state: {} }; + run: async () => { + throw new Error(); }, }), }, @@ -1192,29 +1064,190 @@ describe('TaskManagerRunner', () => { await runner.run(); - const instance = store.update.args[0][0]; - expect(instance.status).toBe('failed'); + expect(store.update).toHaveBeenCalledTimes(1); + const instance = store.update.mock.calls[0][0]; + expect(instance.attempts).toEqual(3); + expect(instance.status).toEqual('idle'); + expect(instance.runAt.getTime()).toEqual( + new Date(Date.now() + intervalSeconds * 1000).getTime() + ); + }); - expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming( - asTaskRunEvent( + describe('TaskEvents', () => { + test('emits TaskEvent when a task is run successfully', async () => { + const id = _.random(1, 20).toString(); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { id, - asErr({ - error, - task: originalInstance, - result: TaskRunResult.Failed, - }) + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) + ); + }); + + test('emits TaskEvent when a recurring task is run successfully', async () => { + const id = _.random(1, 20).toString(); + const runAt = minutesFromNow(_.random(5)); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + schedule: { interval: '1m' }, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { runAt, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) + ); + }); + + test('emits TaskEvent when a task run throws an error', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw error; + }, + }), + }, + }, + }); + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ error, task: instance, result: TaskRunResult.RetryScheduled }) + ) ) - ) - ); - expect(onTaskEvent).toHaveBeenCalledTimes(1); + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + + test('emits TaskEvent when a task run returns an error', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, instance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + schedule: { interval: '1m' }, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { error, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ error, task: instance, result: TaskRunResult.RetryScheduled }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); + + test('emits TaskEvent when a task returns an error and is marked as failed', async () => { + const id = _.random(1, 20).toString(); + const error = new Error('Dangit!'); + const onTaskEvent = jest.fn(); + const { runner, store, instance: originalInstance } = await readyToRunStageSetup({ + onTaskEvent, + instance: { + id, + startedAt: new Date(), + }, + definitions: { + bar: { + title: 'Bar!', + getRetry: () => false, + createTaskRunner: () => ({ + async run() { + return { error, state: {} }; + }, + }), + }, + }, + }); + + await runner.run(); + + const instance = store.update.mock.calls[0][0]; + expect(instance.status).toBe('failed'); + + expect(onTaskEvent).toHaveBeenCalledWith( + withAnyTiming( + asTaskRunEvent( + id, + asErr({ + error, + task: originalInstance, + result: TaskRunResult.Failed, + }) + ) + ) + ); + expect(onTaskEvent).toHaveBeenCalledTimes(1); + }); }); }); interface TestOpts { instance?: Partial; definitions?: TaskDefinitionRegistry; - onTaskEvent?: (event: TaskEvent) => void; + onTaskEvent?: jest.Mock<(event: TaskEvent) => void>; } function withAnyTiming(taskRun: TaskRun) { @@ -1247,20 +1280,16 @@ describe('TaskManagerRunner', () => { ); } - function testOpts(opts: TestOpts) { + async function testOpts(stage: TaskRunningStage, opts: TestOpts) { const callCluster = sinon.stub(); const createTaskRunner = sinon.stub(); const logger = mockLogger(); const instance = mockInstance(opts.instance); - const store = { - update: sinon.stub(), - remove: sinon.stub(), - maxAttempts: 5, - }; + const store = taskStoreMock.create(); - store.update.returns(instance); + store.update.mockResolvedValue(instance); const definitions = new TaskTypeDictionary(logger); definitions.registerTaskDefinitions({ @@ -1274,6 +1303,7 @@ describe('TaskManagerRunner', () => { } const runner = new TaskManagerRunner({ + defaultMaxAttempts: 5, beforeRun: (context) => Promise.resolve(context), beforeMarkRunning: (context) => Promise.resolve(context), logger, @@ -1283,6 +1313,15 @@ describe('TaskManagerRunner', () => { onTaskEvent: opts.onTaskEvent, }); + if (stage === TaskRunningStage.READY_TO_RUN) { + await runner.markTaskAsRunning(); + // as we're testing the ReadyToRun stage specifically, clear mocks cakked by setup + store.update.mockClear(); + if (opts.onTaskEvent) { + opts.onTaskEvent.mockClear(); + } + } + return { callCluster, createTaskRunner, diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index ad5a2e11409ec8..8e061eae460280 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -63,11 +63,22 @@ export interface TaskRunner { markTaskAsRunning: () => Promise; run: () => Promise>; id: string; + stage: string; toString: () => string; } +export enum TaskRunningStage { + PENDING = 'PENDING', + READY_TO_RUN = 'READY_TO_RUN', + RAN = 'RAN', +} +export interface TaskRunning { + timestamp: Date; + stage: Stage; + task: Instance; +} + export interface Updatable { - readonly maxAttempts: number; update(doc: ConcreteTaskInstance): Promise; remove(id: string): Promise; } @@ -78,6 +89,7 @@ type Opts = { instance: ConcreteTaskInstance; store: Updatable; onTaskEvent?: (event: TaskRun | TaskMarkRunning) => void; + defaultMaxAttempts: number; } & Pick; export enum TaskRunResult { @@ -91,6 +103,16 @@ export enum TaskRunResult { Failed = 'Failed', } +// A ConcreteTaskInstance which we *know* has a `startedAt` Date on it +type ConcreteTaskInstanceWithStartedAt = ConcreteTaskInstance & { startedAt: Date }; + +// The three possible stages for a Task Runner - Pending -> ReadyToRun -> Ran +type PendingTask = TaskRunning; +type ReadyToRunTask = TaskRunning; +type RanTask = TaskRunning; + +type TaskRunningInstance = PendingTask | ReadyToRunTask | RanTask; + /** * Runs a background task, ensures that errors are properly handled, * allows for cancellation. @@ -101,13 +123,14 @@ export enum TaskRunResult { */ export class TaskManagerRunner implements TaskRunner { private task?: CancellableTask; - private instance: ConcreteTaskInstance; + private instance: TaskRunningInstance; private definitions: TaskTypeDictionary; private logger: Logger; private bufferedTaskStore: Updatable; private beforeRun: Middleware['beforeRun']; private beforeMarkRunning: Middleware['beforeMarkRunning']; private onTaskEvent: (event: TaskRun | TaskMarkRunning) => void; + private defaultMaxAttempts: number; /** * Creates an instance of TaskManagerRunner. @@ -126,29 +149,38 @@ export class TaskManagerRunner implements TaskRunner { store, beforeRun, beforeMarkRunning, + defaultMaxAttempts, onTaskEvent = identity, }: Opts) { - this.instance = sanitizeInstance(instance); + this.instance = asPending(sanitizeInstance(instance)); this.definitions = definitions; this.logger = logger; this.bufferedTaskStore = store; this.beforeRun = beforeRun; this.beforeMarkRunning = beforeMarkRunning; this.onTaskEvent = onTaskEvent; + this.defaultMaxAttempts = defaultMaxAttempts; } /** * Gets the id of this task instance. */ public get id() { - return this.instance.id; + return this.instance.task.id; } /** * Gets the task type of this task instance. */ public get taskType() { - return this.instance.taskType; + return this.instance.task.taskType; + } + + /** + * Get the stage this TaskRunner is at + */ + public get stage() { + return this.instance.stage; } /** @@ -162,14 +194,21 @@ export class TaskManagerRunner implements TaskRunner { * Gets the time at which this task will expire. */ public get expiration() { - return intervalFromDate(this.instance.startedAt!, this.definition.timeout)!; + return intervalFromDate( + // if the task is running, use it's started at, otherwise use the timestamp at + // which it was last updated + // this allows us to catch tasks that remain in Pending/Finalizing without being + // cleaned up + isReadyToRun(this.instance) ? this.instance.task.startedAt : this.instance.timestamp, + this.definition.timeout + )!; } /** * Gets the duration of the current task run */ public get startedAt() { - return this.instance.startedAt; + return this.instance.task.startedAt; } /** @@ -195,9 +234,16 @@ export class TaskManagerRunner implements TaskRunner { * @returns {Promise>} */ public async run(): Promise> { + if (!isReadyToRun(this.instance)) { + throw new Error( + `Running task ${this} failed as it ${ + isPending(this.instance) ? `isn't ready to be ran` : `has already been ran` + }` + ); + } this.logger.debug(`Running task ${this}`); const modifiedContext = await this.beforeRun({ - taskInstance: this.instance, + taskInstance: this.instance.task, }); const stopTaskTimer = startTaskTimer(); @@ -230,10 +276,16 @@ export class TaskManagerRunner implements TaskRunner { * @returns {Promise} */ public async markTaskAsRunning(): Promise { + if (!isPending(this.instance)) { + throw new Error( + `Marking task ${this} as running has failed as it ${ + isReadyToRun(this.instance) ? `is already running` : `has already been ran` + }` + ); + } performance.mark('markTaskAsRunning_start'); const apmTrans = apm.startTransaction(`taskManager markTaskAsRunning`, 'taskManager'); - apmTrans?.addLabels({ taskType: this.taskType, }); @@ -241,7 +293,7 @@ export class TaskManagerRunner implements TaskRunner { const now = new Date(); try { const { taskInstance } = await this.beforeMarkRunning({ - taskInstance: this.instance, + taskInstance: this.instance.task, }); const attempts = taskInstance.attempts + 1; @@ -258,22 +310,29 @@ export class TaskManagerRunner implements TaskRunner { ); } - this.instance = await this.bufferedTaskStore.update({ - ...taskInstance, - status: TaskStatus.Running, - startedAt: now, - attempts, - retryAt: - (this.instance.schedule - ? maxIntervalFromDate(now, this.instance.schedule!.interval, this.definition.timeout) - : this.getRetryDelay({ - attempts, - // Fake an error. This allows retry logic when tasks keep timing out - // and lets us set a proper "retryAt" value each time. - error: new Error('Task timeout'), - addDuration: this.definition.timeout, - })) ?? null, - }); + this.instance = asReadyToRun( + (await this.bufferedTaskStore.update({ + ...taskInstance, + status: TaskStatus.Running, + startedAt: now, + attempts, + retryAt: + (this.instance.task.schedule + ? maxIntervalFromDate( + now, + this.instance.task.schedule.interval, + this.definition.timeout + ) + : this.getRetryDelay({ + attempts, + // Fake an error. This allows retry logic when tasks keep timing out + // and lets us set a proper "retryAt" value each time. + error: new Error('Task timeout'), + addDuration: this.definition.timeout, + })) ?? null, + // This is a safe convertion as we're setting the startAt above + })) as ConcreteTaskInstanceWithStartedAt + ); const timeUntilClaimExpiresAfterUpdate = howManyMsUntilOwnershipClaimExpires( ownershipClaimedUntil @@ -288,7 +347,7 @@ export class TaskManagerRunner implements TaskRunner { if (apmTrans) apmTrans.end('success'); performanceStopMarkingTaskAsRunning(); - this.onTaskEvent(asTaskMarkRunningEvent(this.id, asOk(this.instance))); + this.onTaskEvent(asTaskMarkRunningEvent(this.id, asOk(this.instance.task))); return true; } catch (error) { if (apmTrans) apmTrans.end('failure'); @@ -299,7 +358,7 @@ export class TaskManagerRunner implements TaskRunner { // try to release claim as an unknown failure prevented us from marking as running mapErr((errReleaseClaim: Error) => { this.logger.error( - `[Task Runner] Task ${this.instance.id} failed to release claim after failure: ${errReleaseClaim}` + `[Task Runner] Task ${this.id} failed to release claim after failure: ${errReleaseClaim}` ); }, await this.releaseClaimAndIncrementAttempts()); } @@ -336,9 +395,9 @@ export class TaskManagerRunner implements TaskRunner { private async releaseClaimAndIncrementAttempts(): Promise> { return promiseResult( this.bufferedTaskStore.update({ - ...this.instance, + ...this.instance.task, status: TaskStatus.Idle, - attempts: this.instance.attempts + 1, + attempts: this.instance.task.attempts + 1, startedAt: null, retryAt: null, ownerId: null, @@ -347,12 +406,12 @@ export class TaskManagerRunner implements TaskRunner { } private shouldTryToScheduleRetry(): boolean { - if (this.instance.schedule) { + if (this.instance.task.schedule) { return true; } - const maxAttempts = this.definition.maxAttempts || this.bufferedTaskStore.maxAttempts; - return this.instance.attempts < maxAttempts; + const maxAttempts = this.definition.maxAttempts || this.defaultMaxAttempts; + return this.instance.task.attempts < maxAttempts; } private rescheduleFailedRun = ( @@ -361,7 +420,7 @@ export class TaskManagerRunner implements TaskRunner { const { state, error } = failureResult; if (this.shouldTryToScheduleRetry() && !isUnrecoverableError(error)) { // if we're retrying, keep the number of attempts - const { schedule, attempts } = this.instance; + const { schedule, attempts } = this.instance.task; const reschedule = failureResult.runAt ? { runAt: failureResult.runAt } @@ -399,7 +458,7 @@ export class TaskManagerRunner implements TaskRunner { // if retrying is possible (new runAt) or this is an recurring task - reschedule mapOk( ({ runAt, schedule: reschedule, state, attempts = 0 }: Partial) => { - const { startedAt, schedule } = this.instance; + const { startedAt, schedule } = this.instance.task; return asOk({ runAt: runAt || intervalFromDate(startedAt!, reschedule?.interval ?? schedule?.interval)!, @@ -413,16 +472,18 @@ export class TaskManagerRunner implements TaskRunner { unwrap )(result); - await this.bufferedTaskStore.update( - defaults( - { - ...fieldUpdates, - // reset fields that track the lifecycle of the concluded `task run` - startedAt: null, - retryAt: null, - ownerId: null, - }, - this.instance + this.instance = asRan( + await this.bufferedTaskStore.update( + defaults( + { + ...fieldUpdates, + // reset fields that track the lifecycle of the concluded `task run` + startedAt: null, + retryAt: null, + ownerId: null, + }, + this.instance.task + ) ) ); @@ -436,7 +497,8 @@ export class TaskManagerRunner implements TaskRunner { private async processResultWhenDone(): Promise { // not a recurring task: clean up by removing the task instance from store try { - await this.bufferedTaskStore.remove(this.instance.id); + await this.bufferedTaskStore.remove(this.id); + this.instance = asRan(this.instance.task); } catch (err) { if (err.statusCode === 404) { this.logger.warn(`Task cleanup of ${this} failed in processing. Was remove called twice?`); @@ -451,7 +513,7 @@ export class TaskManagerRunner implements TaskRunner { result: Result, taskTiming: TaskTiming ): Promise> { - const task = this.instance; + const { task } = this.instance; await eitherAsync( result, async ({ runAt, schedule }: SuccessfulRunResult) => { @@ -528,3 +590,38 @@ function performanceStopMarkingTaskAsRunning() { 'markTaskAsRunning_stop' ); } + +// A type that extracts the Instance type out of TaskRunningStage +// This helps us to better communicate to the developer what the expected "stage" +// in a specific place in the code might be +type InstanceOf = T extends TaskRunning ? I : never; + +function isPending(taskRunning: TaskRunningInstance): taskRunning is PendingTask { + return taskRunning.stage === TaskRunningStage.PENDING; +} +function asPending(task: InstanceOf): PendingTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.PENDING, + task, + }; +} +function isReadyToRun(taskRunning: TaskRunningInstance): taskRunning is ReadyToRunTask { + return taskRunning.stage === TaskRunningStage.READY_TO_RUN; +} +function asReadyToRun( + task: InstanceOf +): ReadyToRunTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.READY_TO_RUN, + task, + }; +} +function asRan(task: InstanceOf): RanTask { + return { + timestamp: new Date(), + stage: TaskRunningStage.RAN, + task, + }; +} diff --git a/x-pack/plugins/task_manager/server/task_scheduling.test.ts b/x-pack/plugins/task_manager/server/task_scheduling.test.ts index e495d416d5ab86..b142f2091291ed 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.test.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.test.ts @@ -7,13 +7,14 @@ import _ from 'lodash'; import { Subject } from 'rxjs'; -import { none } from 'fp-ts/lib/Option'; +import { none, some } from 'fp-ts/lib/Option'; import { asTaskMarkRunningEvent, asTaskRunEvent, asTaskClaimEvent, asTaskRunRequestEvent, + TaskClaimErrorType, } from './task_events'; import { TaskLifecycleEvent } from './polling_lifecycle'; import { taskPollingLifecycleMock } from './polling_lifecycle.mock'; @@ -24,17 +25,28 @@ import { createInitialMiddleware } from './lib/middleware'; import { taskStoreMock } from './task_store.mock'; import { TaskRunResult } from './task_running'; import { mockLogger } from './test_utils'; +import { TaskTypeDictionary } from './task_type_dictionary'; describe('TaskScheduling', () => { const mockTaskStore = taskStoreMock.create({}); const mockTaskManager = taskPollingLifecycleMock.create({}); + const definitions = new TaskTypeDictionary(mockLogger()); const taskSchedulingOpts = { taskStore: mockTaskStore, taskPollingLifecycle: mockTaskManager, logger: mockLogger(), middleware: createInitialMiddleware(), + definitions, }; + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + beforeEach(() => { jest.resetAllMocks(); }); @@ -114,7 +126,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskRunEvent(id, asOk({ task, result: TaskRunResult.Success }))); return expect(result).resolves.toEqual({ id }); @@ -131,7 +143,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskMarkRunningEvent(id, asOk(task))); events$.next( @@ -161,7 +173,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskMarkRunningEvent(id, asErr(new Error('some thing gone wrong')))); @@ -183,7 +195,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it does not exist`) @@ -192,6 +209,34 @@ describe('TaskScheduling', () => { expect(mockTaskStore.getLifecycle).toHaveBeenCalledWith(id); }); + test('when a task claim due to insufficient capacity we return an explciit message', async () => { + const events$ = new Subject(); + const id = '01ddff11-e88a-4d13-bc4e-256164e755e2'; + + mockTaskStore.getLifecycle.mockResolvedValue(TaskLifecycleResult.NotFound); + + const taskScheduling = new TaskScheduling({ + ...taskSchedulingOpts, + taskPollingLifecycle: taskPollingLifecycleMock.create({ events$ }), + }); + + const result = taskScheduling.runNow(id); + + const task = mockTask({ id, taskType: 'foo' }); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: some(task), errorType: TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY }) + ) + ); + + await expect(result).rejects.toEqual( + new Error( + `Failed to run task "${id}" as we would exceed the max concurrency of "${task.taskType}" which is 2. Rescheduled the task to ensure it is picked up as soon as possible.` + ) + ); + }); + test('when a task claim fails we ensure the task isnt already claimed', async () => { const events$ = new Subject(); const id = '01ddff11-e88a-4d13-bc4e-256164e755e2'; @@ -205,7 +250,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it is currently running`) @@ -227,7 +277,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toEqual( new Error(`Failed to run task "${id}" as it is currently running`) @@ -270,7 +325,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toMatchInlineSnapshot( `[Error: Failed to run task "01ddff11-e88a-4d13-bc4e-256164e755e2" for unknown reason (Current Task Lifecycle is "idle")]` @@ -292,7 +352,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - events$.next(asTaskClaimEvent(id, asErr(none))); + events$.next( + asTaskClaimEvent( + id, + asErr({ task: none, errorType: TaskClaimErrorType.CLAIMED_BY_ID_NOT_RETURNED }) + ) + ); await expect(result).rejects.toMatchInlineSnapshot( `[Error: Failed to run task "01ddff11-e88a-4d13-bc4e-256164e755e2" for unknown reason (Current Task Lifecycle is "failed")]` @@ -313,7 +378,7 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); - const task = { id } as ConcreteTaskInstance; + const task = mockTask({ id }); const otherTask = { id: differentTask } as ConcreteTaskInstance; events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskClaimEvent(differentTask, asOk(otherTask))); @@ -338,3 +403,23 @@ describe('TaskScheduling', () => { }); }); }); + +function mockTask(overrides: Partial = {}): ConcreteTaskInstance { + return { + id: 'claimed-by-id', + runAt: new Date(), + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Claiming, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: '', + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + ...overrides, + }; +} diff --git a/x-pack/plugins/task_manager/server/task_scheduling.ts b/x-pack/plugins/task_manager/server/task_scheduling.ts index 8ccedb85c560df..29e83ec911b795 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.ts @@ -8,7 +8,7 @@ import { filter } from 'rxjs/operators'; import { pipe } from 'fp-ts/lib/pipeable'; -import { Option, map as mapOptional, getOrElse } from 'fp-ts/lib/Option'; +import { Option, map as mapOptional, getOrElse, isSome } from 'fp-ts/lib/Option'; import { Logger } from '../../../../src/core/server'; import { asOk, either, map, mapErr, promiseResult } from './lib/result_type'; @@ -20,6 +20,8 @@ import { ErroredTask, OkResultOf, ErrResultOf, + ClaimTaskErr, + TaskClaimErrorType, } from './task_events'; import { Middleware } from './lib/middleware'; import { @@ -33,6 +35,7 @@ import { import { TaskStore } from './task_store'; import { ensureDeprecatedFieldsAreCorrected } from './lib/correct_deprecated_fields'; import { TaskLifecycleEvent, TaskPollingLifecycle } from './polling_lifecycle'; +import { TaskTypeDictionary } from './task_type_dictionary'; const VERSION_CONFLICT_STATUS = 409; @@ -41,6 +44,7 @@ export interface TaskSchedulingOpts { taskStore: TaskStore; taskPollingLifecycle: TaskPollingLifecycle; middleware: Middleware; + definitions: TaskTypeDictionary; } interface RunNowResult { @@ -52,6 +56,7 @@ export class TaskScheduling { private taskPollingLifecycle: TaskPollingLifecycle; private logger: Logger; private middleware: Middleware; + private definitions: TaskTypeDictionary; /** * Initializes the task manager, preventing any further addition of middleware, @@ -63,6 +68,7 @@ export class TaskScheduling { this.middleware = opts.middleware; this.taskPollingLifecycle = opts.taskPollingLifecycle; this.store = opts.taskStore; + this.definitions = opts.definitions; } /** @@ -122,10 +128,27 @@ export class TaskScheduling { .pipe(filter(({ id }: TaskLifecycleEvent) => id === taskId)) .subscribe((taskEvent: TaskLifecycleEvent) => { if (isTaskClaimEvent(taskEvent)) { - mapErr(async (error: Option) => { + mapErr(async (error: ClaimTaskErr) => { // reject if any error event takes place for the requested task subscription.unsubscribe(); - return reject(await this.identifyTaskFailureReason(taskId, error)); + if ( + isSome(error.task) && + error.errorType === TaskClaimErrorType.CLAIMED_BY_ID_OUT_OF_CAPACITY + ) { + const task = error.task.value; + const definition = this.definitions.get(task.taskType); + return reject( + new Error( + `Failed to run task "${taskId}" as we would exceed the max concurrency of "${ + definition?.title ?? task.taskType + }" which is ${ + definition?.maxConcurrency + }. Rescheduled the task to ensure it is picked up as soon as possible.` + ) + ); + } else { + return reject(await this.identifyTaskFailureReason(taskId, error.task)); + } }, taskEvent.event); } else { either, ErrResultOf>( diff --git a/x-pack/plugins/task_manager/server/task_store.mock.ts b/x-pack/plugins/task_manager/server/task_store.mock.ts index d4f863af6fe3b1..38d570f96220bc 100644 --- a/x-pack/plugins/task_manager/server/task_store.mock.ts +++ b/x-pack/plugins/task_manager/server/task_store.mock.ts @@ -5,38 +5,27 @@ * 2.0. */ -import { Observable, Subject } from 'rxjs'; -import { TaskClaim } from './task_events'; - import { TaskStore } from './task_store'; interface TaskStoreOptions { - maxAttempts?: number; index?: string; taskManagerId?: string; - events?: Observable; } export const taskStoreMock = { - create({ - maxAttempts = 0, - index = '', - taskManagerId = '', - events = new Subject(), - }: TaskStoreOptions) { + create({ index = '', taskManagerId = '' }: TaskStoreOptions = {}) { const mocked = ({ + convertToSavedObjectIds: jest.fn(), update: jest.fn(), remove: jest.fn(), schedule: jest.fn(), - claimAvailableTasks: jest.fn(), bulkUpdate: jest.fn(), get: jest.fn(), getLifecycle: jest.fn(), fetch: jest.fn(), aggregate: jest.fn(), - maxAttempts, + updateByQuery: jest.fn(), index, taskManagerId, - events, } as unknown) as jest.Mocked; return mocked; }, diff --git a/x-pack/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts index dbf13a5f272810..25ee8cb0e23745 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -6,19 +6,16 @@ */ import _ from 'lodash'; -import uuid from 'uuid'; -import { filter, take, first } from 'rxjs/operators'; -import { Option, some, none } from 'fp-ts/lib/Option'; +import { first } from 'rxjs/operators'; import { TaskInstance, TaskStatus, TaskLifecycleResult, SerializedConcreteTaskInstance, - ConcreteTaskInstance, } from './task'; import { elasticsearchServiceMock } from '../../../../src/core/server/mocks'; -import { StoreOpts, OwnershipClaimingOpts, TaskStore, SearchOpts } from './task_store'; +import { TaskStore, SearchOpts } from './task_store'; import { savedObjectsRepositoryMock } from 'src/core/server/mocks'; import { SavedObjectsSerializer, @@ -26,12 +23,8 @@ import { SavedObjectAttributes, SavedObjectsErrorHelpers, } from 'src/core/server'; -import { asTaskClaimEvent, TaskEvent } from './task_events'; -import { asOk, asErr } from './lib/result_type'; import { TaskTypeDictionary } from './task_type_dictionary'; import { RequestEvent } from '@elastic/elasticsearch/lib/Transport'; -import { Search, UpdateByQuery } from '@elastic/elasticsearch/api/requestParams'; -import { BoolClauseWithAnyCondition, TermFilter } from './queries/query_clauses'; import { mockLogger } from './test_utils'; const savedObjectsClient = savedObjectsRepositoryMock.create(); @@ -76,7 +69,6 @@ describe('TaskStore', () => { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -209,7 +201,6 @@ describe('TaskStore', () => { taskManagerId: '', serializer, esClient, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -265,809 +256,6 @@ describe('TaskStore', () => { }); }); - describe('claimAvailableTasks', () => { - async function testClaimAvailableTasks({ - opts = {}, - hits = generateFakeTasks(1), - claimingOpts, - versionConflicts = 2, - }: { - opts: Partial; - hits?: unknown[]; - claimingOpts: OwnershipClaimingOpts; - versionConflicts?: number; - }) { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.search.mockResolvedValue(asApiResponse({ hits: { hits } })); - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: hits.length + versionConflicts, - updated: hits.length, - version_conflicts: versionConflicts, - }) - ); - - const store = new TaskStore({ - esClient, - maxAttempts: 2, - definitions: taskDefinitions, - serializer, - savedObjectsRepository: savedObjectsClient, - taskManagerId: '', - index: '', - ...opts, - }); - - const result = await store.claimAvailableTasks(claimingOpts); - - expect(esClient.updateByQuery.mock.calls[0][0]).toMatchObject({ - max_docs: claimingOpts.size, - }); - expect(esClient.search.mock.calls[0][0]).toMatchObject({ body: { size: claimingOpts.size } }); - return { - result, - args: { - search: esClient.search.mock.calls[0][0]! as Search<{ - query: BoolClauseWithAnyCondition; - size: number; - sort: string | string[]; - }>, - updateByQuery: esClient.updateByQuery.mock.calls[0][0]! as UpdateByQuery<{ - query: BoolClauseWithAnyCondition; - size: number; - sort: string | string[]; - script: object; - }>, - }, - }; - } - - test('it returns normally with no tasks when the index does not exist.', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: 0, - updated: 0, - }) - ); - const store = new TaskStore({ - index: 'tasky', - taskManagerId: '', - serializer, - esClient, - definitions: taskDefinitions, - maxAttempts: 2, - savedObjectsRepository: savedObjectsClient, - }); - const { docs } = await store.claimAvailableTasks({ - claimOwnershipUntil: new Date(), - size: 10, - }); - expect(esClient.updateByQuery.mock.calls[0][0]).toMatchObject({ - ignore_unavailable: true, - max_docs: 10, - }); - expect(docs.length).toBe(0); - }); - - test('it filters claimed tasks down by supported types, maxAttempts, status, and runAt', async () => { - const maxAttempts = _.random(2, 43); - const customMaxAttempts = _.random(44, 100); - - const definitions = new TaskTypeDictionary(mockLogger()); - definitions.registerTaskDefinitions({ - foo: { - title: 'foo', - createTaskRunner: jest.fn(), - }, - bar: { - title: 'bar', - maxAttempts: customMaxAttempts, - createTaskRunner: jest.fn(), - }, - }); - - const { - args: { - updateByQuery: { body: { query, sort } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - maxAttempts, - definitions, - }, - claimingOpts: { claimOwnershipUntil: new Date(), size: 10 }, - }); - expect(query).toMatchObject({ - bool: { - must: [ - { term: { type: 'task' } }, - { - bool: { - must: [ - { - bool: { - must: [ - { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'task.status': 'idle' } }, - { range: { 'task.runAt': { lte: 'now' } } }, - ], - }, - }, - { - bool: { - must: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }, - { range: { 'task.retryAt': { lte: 'now' } } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], - filter: [ - { - bool: { - must_not: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - must: { range: { 'task.retryAt': { gt: 'now' } } }, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }); - expect(sort).toMatchObject([ - { - _script: { - type: 'number', - order: 'asc', - script: { - lang: 'painless', - source: ` -if (doc['task.retryAt'].size()!=0) { - return doc['task.retryAt'].value.toInstant().toEpochMilli(); -} -if (doc['task.runAt'].size()!=0) { - return doc['task.runAt'].value.toInstant().toEpochMilli(); -} - `, - }, - }, - }, - ]); - }); - - test('it supports claiming specific tasks by id', async () => { - const maxAttempts = _.random(2, 43); - const customMaxAttempts = _.random(44, 100); - const definitions = new TaskTypeDictionary(mockLogger()); - const taskManagerId = uuid.v1(); - const fieldUpdates = { - ownerId: taskManagerId, - retryAt: new Date(Date.now()), - }; - definitions.registerTaskDefinitions({ - foo: { - title: 'foo', - createTaskRunner: jest.fn(), - }, - bar: { - title: 'bar', - maxAttempts: customMaxAttempts, - createTaskRunner: jest.fn(), - }, - }); - const { - args: { - updateByQuery: { body: { query, script, sort } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - maxAttempts, - definitions, - }, - claimingOpts: { - claimOwnershipUntil: new Date(), - size: 10, - claimTasksById: [ - '33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - }, - }); - - expect(query).toMatchObject({ - bool: { - must: [ - { term: { type: 'task' } }, - { - bool: { - must: [ - { - pinned: { - ids: [ - 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - organic: { - bool: { - must: [ - { - bool: { - should: [ - { - bool: { - must: [ - { term: { 'task.status': 'idle' } }, - { range: { 'task.runAt': { lte: 'now' } } }, - ], - }, - }, - { - bool: { - must: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }, - { range: { 'task.retryAt': { lte: 'now' } } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - ], - filter: [ - { - bool: { - must_not: [ - { - bool: { - should: [ - { term: { 'task.status': 'running' } }, - { term: { 'task.status': 'claiming' } }, - ], - must: { range: { 'task.retryAt': { gt: 'now' } } }, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }); - - expect(script).toMatchObject({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} - } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById: [ - 'task:33c6977a-ed6d-43bd-98d9-3f827f7b7cd8', - 'task:a208b22c-14ec-4fb4-995f-d2ff7a3b03b8', - ], - registeredTaskTypes: ['foo', 'bar'], - taskMaxAttempts: { - bar: customMaxAttempts, - foo: maxAttempts, - }, - }, - }); - - expect(sort).toMatchObject([ - '_score', - { - _script: { - type: 'number', - order: 'asc', - script: { - lang: 'painless', - source: ` -if (doc['task.retryAt'].size()!=0) { - return doc['task.retryAt'].value.toInstant().toEpochMilli(); -} -if (doc['task.runAt'].size()!=0) { - return doc['task.runAt'].value.toInstant().toEpochMilli(); -} - `, - }, - }, - }, - ]); - }); - - test('it claims tasks by setting their ownerId, status and retryAt', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const fieldUpdates = { - ownerId: taskManagerId, - retryAt: claimOwnershipUntil, - }; - const { - args: { - updateByQuery: { body: { script } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - }); - expect(script).toMatchObject({ - source: ` - if (params.registeredTaskTypes.contains(ctx._source.task.taskType)) { - if (ctx._source.task.schedule != null || ctx._source.task.attempts < params.taskMaxAttempts[ctx._source.task.taskType] || params.claimTasksById.contains(ctx._id)) { - ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) - .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) - .join(' ')} - } else { - ctx._source.task.status = "failed"; - } - } else { - ctx._source.task.status = "unrecognized"; - } - `, - lang: 'painless', - params: { - fieldUpdates, - claimTasksById: [], - registeredTaskTypes: ['report', 'dernstraight', 'yawn'], - taskMaxAttempts: { - dernstraight: 2, - report: 2, - yawn: 2, - }, - }, - }); - }); - - test('it filters out running tasks', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - // this is invalid as it doesn't have the `type` prefix - _id: 'bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs }, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it filters out invalid tasks that arent SavedObjects', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs } = {}, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it returns task objects', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const { - result: { docs } = {}, - args: { - search: { body: { query } = {} }, - }, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: 10, - }, - hits: tasks, - }); - - expect(query?.bool?.must).toContainEqual({ - bool: { - must: [ - { - term: { - 'task.ownerId': taskManagerId, - }, - }, - { term: { 'task.status': 'claiming' } }, - ], - }, - }); - - expect(docs).toMatchObject([ - { - attempts: 0, - id: 'aaa', - schedule: undefined, - params: { hello: 'world' }, - runAt, - scope: ['reporting'], - state: { baby: 'Henhen' }, - status: 'claiming', - taskType: 'foo', - user: 'jimbo', - ownerId: taskManagerId, - }, - { - attempts: 2, - id: 'bbb', - schedule: { interval: '5m' }, - params: { shazm: 1 }, - runAt, - scope: ['reporting', 'ceo'], - state: { henry: 'The 8th' }, - status: 'claiming', - taskType: 'bar', - user: 'dabo', - ownerId: taskManagerId, - }, - ]); - }); - - test('it returns version_conflicts that do not include conflicts that were proceeded against', async () => { - const taskManagerId = uuid.v1(); - const claimOwnershipUntil = new Date(Date.now()); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:aaa', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:bbb', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - const maxDocs = 10; - const { - result: { stats: { tasksUpdated, tasksConflicted, tasksClaimed } = {} } = {}, - } = await testClaimAvailableTasks({ - opts: { - taskManagerId, - }, - claimingOpts: { - claimOwnershipUntil, - size: maxDocs, - }, - hits: tasks, - // assume there were 20 version conflists, but thanks to `conflicts="proceed"` - // we proceeded to claim tasks - versionConflicts: 20, - }); - - expect(tasksUpdated).toEqual(2); - // ensure we only count conflicts that *may* have counted against max_docs, no more than that - expect(tasksConflicted).toEqual(10 - tasksUpdated!); - expect(tasksClaimed).toEqual(2); - }); - - test('pushes error from saved objects client to errors$', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - const store = new TaskStore({ - index: 'tasky', - taskManagerId: '', - serializer, - esClient, - definitions: taskDefinitions, - maxAttempts: 2, - savedObjectsRepository: savedObjectsClient, - }); - - const firstErrorPromise = store.errors$.pipe(first()).toPromise(); - esClient.updateByQuery.mockRejectedValue(new Error('Failure')); - await expect( - store.claimAvailableTasks({ - claimOwnershipUntil: new Date(), - size: 10, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Failure"`); - expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: Failure]`); - }); - }); - describe('update', () => { let store: TaskStore; let esClient: ReturnType['asInternalUser']; @@ -1079,7 +267,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1179,7 +366,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1219,7 +405,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1251,7 +436,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1335,7 +519,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1355,7 +538,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1373,7 +555,6 @@ if (doc['task.runAt'].size()!=0) { taskManagerId: '', serializer, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, - maxAttempts: 2, definitions: taskDefinitions, savedObjectsRepository: savedObjectsClient, }); @@ -1381,283 +562,8 @@ if (doc['task.runAt'].size()!=0) { return expect(store.getLifecycle(randomId())).rejects.toThrow('Bad Request'); }); }); - - describe('task events', () => { - function generateTasks() { - const taskManagerId = uuid.v1(); - const runAt = new Date(); - const tasks = [ - { - _id: 'task:claimed-by-id', - _source: { - type: 'task', - task: { - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming', - params: '{ "hello": "world" }', - state: '{ "baby": "Henhen" }', - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 1, - _primary_term: 2, - sort: ['a', 1], - }, - { - _id: 'task:claimed-by-schedule', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - { - _id: 'task:already-running', - _source: { - type: 'task', - task: { - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running', - params: '{ "shazm": 1 }', - state: '{ "henry": "The 8th" }', - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }, - }, - _seq_no: 3, - _primary_term: 4, - sort: ['b', 2], - }, - ]; - - return { taskManagerId, runAt, tasks }; - } - - function instantiateStoreWithMockedApiResponses() { - const { taskManagerId, runAt, tasks } = generateTasks(); - - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.search.mockResolvedValue(asApiResponse({ hits: { hits: tasks } })); - esClient.updateByQuery.mockResolvedValue( - asApiResponse({ - total: tasks.length, - updated: tasks.length, - }) - ); - - const store = new TaskStore({ - esClient, - maxAttempts: 2, - definitions: taskDefinitions, - serializer, - savedObjectsRepository: savedObjectsClient, - taskManagerId, - index: '', - }); - - return { taskManagerId, runAt, store }; - } - - test('emits an event when a task is succesfully claimed by id', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'claimed-by-id' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['claimed-by-id'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'claimed-by-id', - asOk({ - id: 'claimed-by-id', - runAt, - taskType: 'foo', - schedule: undefined, - attempts: 0, - status: 'claiming' as TaskStatus, - params: { hello: 'world' }, - state: { baby: 'Henhen' }, - user: 'jimbo', - scope: ['reporting'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ); - }); - - test('emits an event when a task is succesfully by scheduling', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'claimed-by-schedule' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['claimed-by-id'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'claimed-by-schedule', - asOk({ - id: 'claimed-by-schedule', - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'claiming' as TaskStatus, - params: { shazm: 1 }, - state: { henry: 'The 8th' }, - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ); - }); - - test('emits an event when the store fails to claim a required task by id', async () => { - const { taskManagerId, runAt, store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'already-running' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['already-running'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject( - asTaskClaimEvent( - 'already-running', - asErr( - some({ - id: 'already-running', - runAt, - taskType: 'bar', - schedule: { interval: '5m' }, - attempts: 2, - status: 'running' as TaskStatus, - params: { shazm: 1 }, - state: { henry: 'The 8th' }, - user: 'dabo', - scope: ['reporting', 'ceo'], - ownerId: taskManagerId, - startedAt: null, - retryAt: null, - scheduledAt: new Date(), - }) - ) - ) - ); - }); - - test('emits an event when the store fails to find a task which was required by id', async () => { - const { store } = instantiateStoreWithMockedApiResponses(); - - const promise = store.events - .pipe( - filter( - (event: TaskEvent>) => - event.id === 'unknown-task' - ), - take(1) - ) - .toPromise(); - - await store.claimAvailableTasks({ - claimTasksById: ['unknown-task'], - claimOwnershipUntil: new Date(), - size: 10, - }); - - const event = await promise; - expect(event).toMatchObject(asTaskClaimEvent('unknown-task', asErr(none))); - }); - }); }); -function generateFakeTasks(count: number = 1) { - return _.times(count, (index) => ({ - _id: `task:id-${index}`, - _source: { - type: 'task', - task: {}, - }, - _seq_no: _.random(1, 5), - _primary_term: _.random(1, 5), - sort: ['a', _.random(1, 5)], - })); -} - const asApiResponse = (body: T): RequestEvent => ({ body, diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index b72f1826b813bf..0b54f2779065f6 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -8,13 +8,9 @@ /* * This module contains helpers for managing the task manager storage layer. */ -import apm from 'elastic-apm-node'; -import { Subject, Observable } from 'rxjs'; -import { omit, difference, partition, map, defaults } from 'lodash'; - -import { some, none } from 'fp-ts/lib/Option'; - -import { SearchResponse, UpdateDocumentByQueryResponse } from 'elasticsearch'; +import { Subject } from 'rxjs'; +import { omit, defaults } from 'lodash'; +import { ReindexResponseBase, SearchResponse, UpdateDocumentByQueryResponse } from 'elasticsearch'; import { SavedObject, SavedObjectsSerializer, @@ -32,38 +28,15 @@ import { TaskLifecycle, TaskLifecycleResult, SerializedConcreteTaskInstance, - TaskStatus, } from './task'; -import { TaskClaim, asTaskClaimEvent } from './task_events'; - -import { - asUpdateByQuery, - shouldBeOneOf, - mustBeAllOf, - filterDownBy, - asPinnedQuery, - matchesClauses, - SortOptions, -} from './queries/query_clauses'; - -import { - updateFieldsAndMarkAsFailed, - IdleTaskWithExpiredRunAt, - InactiveTasks, - RunningOrClaimingTaskWithExpiredRetryAt, - SortByRunAtAndRetryAt, - tasksClaimedByOwner, -} from './queries/mark_available_tasks_as_claimed'; import { TaskTypeDictionary } from './task_type_dictionary'; - import { ESSearchResponse, ESSearchBody } from '../../../typings/elasticsearch'; export interface StoreOpts { esClient: ElasticsearchClient; index: string; taskManagerId: string; - maxAttempts: number; definitions: TaskTypeDictionary; savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; @@ -88,25 +61,10 @@ export interface UpdateByQueryOpts extends SearchOpts { max_docs?: number; } -export interface OwnershipClaimingOpts { - claimOwnershipUntil: Date; - claimTasksById?: string[]; - size: number; -} - export interface FetchResult { docs: ConcreteTaskInstance[]; } -export interface ClaimOwnershipResult { - stats: { - tasksUpdated: number; - tasksConflicted: number; - tasksClaimed: number; - }; - docs: ConcreteTaskInstance[]; -} - export type BulkUpdateResult = Result< ConcreteTaskInstance, { entity: ConcreteTaskInstance; error: Error } @@ -123,7 +81,6 @@ export interface UpdateByQueryResult { * interface into the index. */ export class TaskStore { - public readonly maxAttempts: number; public readonly index: string; public readonly taskManagerId: string; public readonly errors$ = new Subject(); @@ -132,14 +89,12 @@ export class TaskStore { private definitions: TaskTypeDictionary; private savedObjectsRepository: ISavedObjectsRepository; private serializer: SavedObjectsSerializer; - private events$: Subject; /** * Constructs a new TaskStore. * @param {StoreOpts} opts * @prop {esClient} esClient - An elasticsearch client * @prop {string} index - The name of the task manager index - * @prop {number} maxAttempts - The maximum number of attempts before a task will be abandoned * @prop {TaskDefinition} definition - The definition of the task being run * @prop {serializer} - The saved object serializer * @prop {savedObjectsRepository} - An instance to the saved objects repository @@ -148,21 +103,22 @@ export class TaskStore { this.esClient = opts.esClient; this.index = opts.index; this.taskManagerId = opts.taskManagerId; - this.maxAttempts = opts.maxAttempts; this.definitions = opts.definitions; this.serializer = opts.serializer; this.savedObjectsRepository = opts.savedObjectsRepository; - this.events$ = new Subject(); } - public get events(): Observable { - return this.events$; + /** + * Convert ConcreteTaskInstance Ids to match their SavedObject format as serialized + * in Elasticsearch + * @param tasks - The task being scheduled. + */ + public convertToSavedObjectIds( + taskIds: Array + ): Array { + return taskIds.map((id) => this.serializer.generateRawId(undefined, 'task', id)); } - private emitEvents = (events: TaskClaim[]) => { - events.forEach((event) => this.events$.next(event)); - }; - /** * Schedules a task. * @@ -201,144 +157,6 @@ export class TaskStore { }); } - /** - * Claims available tasks from the index, which are ready to be run. - * - runAt is now or past - * - is not currently claimed by any instance of Kibana - * - has a type that is in our task definitions - * - * @param {OwnershipClaimingOpts} options - * @returns {Promise} - */ - public claimAvailableTasks = async ({ - claimOwnershipUntil, - claimTasksById = [], - size, - }: OwnershipClaimingOpts): Promise => { - const claimTasksByIdWithRawIds = claimTasksById.map((id) => - this.serializer.generateRawId(undefined, 'task', id) - ); - - const { - updated: tasksUpdated, - version_conflicts: tasksConflicted, - } = await this.markAvailableTasksAsClaimed(claimOwnershipUntil, claimTasksByIdWithRawIds, size); - - const docs = - tasksUpdated > 0 ? await this.sweepForClaimedTasks(claimTasksByIdWithRawIds, size) : []; - - const [documentsReturnedById, documentsClaimedBySchedule] = partition(docs, (doc) => - claimTasksById.includes(doc.id) - ); - - const [documentsClaimedById, documentsRequestedButNotClaimed] = partition( - documentsReturnedById, - // we filter the schduled tasks down by status is 'claiming' in the esearch, - // but we do not apply this limitation on tasks claimed by ID so that we can - // provide more detailed error messages when we fail to claim them - (doc) => doc.status === TaskStatus.Claiming - ); - - const documentsRequestedButNotReturned = difference( - claimTasksById, - map(documentsReturnedById, 'id') - ); - - this.emitEvents([ - ...documentsClaimedById.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), - ...documentsClaimedBySchedule.map((doc) => asTaskClaimEvent(doc.id, asOk(doc))), - ...documentsRequestedButNotClaimed.map((doc) => asTaskClaimEvent(doc.id, asErr(some(doc)))), - ...documentsRequestedButNotReturned.map((id) => asTaskClaimEvent(id, asErr(none))), - ]); - - return { - stats: { - tasksUpdated, - tasksConflicted, - tasksClaimed: documentsClaimedById.length + documentsClaimedBySchedule.length, - }, - docs: docs.filter((doc) => doc.status === TaskStatus.Claiming), - }; - }; - - private async markAvailableTasksAsClaimed( - claimOwnershipUntil: OwnershipClaimingOpts['claimOwnershipUntil'], - claimTasksById: OwnershipClaimingOpts['claimTasksById'], - size: OwnershipClaimingOpts['size'] - ): Promise { - const registeredTaskTypes = this.definitions.getAllTypes(); - const taskMaxAttempts = [...this.definitions].reduce((accumulator, [type, { maxAttempts }]) => { - return { ...accumulator, [type]: maxAttempts || this.maxAttempts }; - }, {}); - const queryForScheduledTasks = mustBeAllOf( - // Either a task with idle status and runAt <= now or - // status running or claiming with a retryAt <= now. - shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) - ); - - // The documents should be sorted by runAt/retryAt, unless there are pinned - // tasks being queried, in which case we want to sort by score first, and then - // the runAt/retryAt. That way we'll get the pinned tasks first. Note that - // the score seems to favor newer documents rather than older documents, so - // if there are not pinned tasks being queried, we do NOT want to sort by score - // at all, just by runAt/retryAt. - const sort: SortOptions = [SortByRunAtAndRetryAt]; - if (claimTasksById && claimTasksById.length) { - sort.unshift('_score'); - } - - const apmTrans = apm.startTransaction(`taskManager markAvailableTasksAsClaimed`, 'taskManager'); - const result = await this.updateByQuery( - asUpdateByQuery({ - query: matchesClauses( - mustBeAllOf( - claimTasksById && claimTasksById.length - ? asPinnedQuery(claimTasksById, queryForScheduledTasks) - : queryForScheduledTasks - ), - filterDownBy(InactiveTasks) - ), - update: updateFieldsAndMarkAsFailed( - { - ownerId: this.taskManagerId, - retryAt: claimOwnershipUntil, - }, - claimTasksById || [], - registeredTaskTypes, - taskMaxAttempts - ), - sort, - }), - { - max_docs: size, - } - ); - - if (apmTrans) apmTrans.end(); - return result; - } - - /** - * Fetches tasks from the index, which are owned by the current Kibana instance - */ - private async sweepForClaimedTasks( - claimTasksById: OwnershipClaimingOpts['claimTasksById'], - size: OwnershipClaimingOpts['size'] - ): Promise { - const claimedTasksQuery = tasksClaimedByOwner(this.taskManagerId); - const { docs } = await this.search({ - query: - claimTasksById && claimTasksById.length - ? asPinnedQuery(claimTasksById, claimedTasksQuery) - : claimedTasksQuery, - size, - sort: SortByRunAtAndRetryAt, - seq_no_primary_term: true, - }); - - return docs; - } - /** * Updates the specified doc in the index, returning the doc * with its version up to date. @@ -527,7 +345,7 @@ export class TaskStore { return body; } - private async updateByQuery( + public async updateByQuery( opts: UpdateByQuerySearchOpts = {}, // eslint-disable-next-line @typescript-eslint/naming-convention { max_docs: max_docs }: UpdateByQueryOpts = {} @@ -549,17 +367,11 @@ export class TaskStore { }, }); - /** - * When we run updateByQuery with conflicts='proceed', it's possible for the `version_conflicts` - * to count against the specified `max_docs`, as per https://github.com/elastic/elasticsearch/issues/63671 - * In order to correct for that happening, we only count `version_conflicts` if we haven't updated as - * many docs as we could have. - * This is still no more than an estimation, as there might have been less docuemnt to update that the - * `max_docs`, but we bias in favour of over zealous `version_conflicts` as that's the best indicator we - * have for an unhealthy cluster distribution of Task Manager polling intervals - */ - const conflictsCorrectedForContinuation = - max_docs && version_conflicts + updated > max_docs ? max_docs - updated : version_conflicts; + const conflictsCorrectedForContinuation = correctVersionConflictsForContinuation( + updated, + version_conflicts, + max_docs + ); return { total, @@ -572,6 +384,22 @@ export class TaskStore { } } } +/** + * When we run updateByQuery with conflicts='proceed', it's possible for the `version_conflicts` + * to count against the specified `max_docs`, as per https://github.com/elastic/elasticsearch/issues/63671 + * In order to correct for that happening, we only count `version_conflicts` if we haven't updated as + * many docs as we could have. + * This is still no more than an estimation, as there might have been less docuemnt to update that the + * `max_docs`, but we bias in favour of over zealous `version_conflicts` as that's the best indicator we + * have for an unhealthy cluster distribution of Task Manager polling intervals + */ +export function correctVersionConflictsForContinuation( + updated: ReindexResponseBase['updated'], + versionConflicts: ReindexResponseBase['version_conflicts'], + maxDocs?: number +) { + return maxDocs && versionConflicts + updated > maxDocs ? maxDocs - updated : versionConflicts; +} function taskInstanceToAttributes(doc: TaskInstance): SerializedConcreteTaskInstance { return { diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.ts index 4230eb9ce4b737..63a0548d79d322 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.ts @@ -28,6 +28,10 @@ export class TaskTypeDictionary { return [...this.definitions.keys()]; } + public getAllDefinitions() { + return [...this.definitions.values()]; + } + public has(type: string) { return this.definitions.has(type); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5e0bf7501eb118..4d94539a514d1d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -660,10 +660,6 @@ "dashboard.topNav.saveModal.storeTimeWithDashboardFormRowHelpText": "有効化すると、ダッシュボードが読み込まれるごとに現在選択された時刻の時間フィルターが変更されます。", "dashboard.topNav.saveModal.storeTimeWithDashboardFormRowLabel": "ダッシュボードに時刻を保存", "dashboard.topNav.showCloneModal.dashboardCopyTitle": "{title}のコピー", - "dashboard.topNave.addButtonAriaLabel": "ライブラリ", - "dashboard.topNave.addConfigDescription": "既存のビジュアライゼーションをダッシュボードに追加", - "dashboard.topNave.addNewButtonAriaLabel": "パネルの作成", - "dashboard.topNave.addNewConfigDescription": "このダッシュボードに新規パネルを作成", "dashboard.topNave.cancelButtonAriaLabel": "キャンセル", "dashboard.topNave.cloneButtonAriaLabel": "クローンを作成", "dashboard.topNave.cloneConfigDescription": "ダッシュボードのコピーを作成します", @@ -5455,9 +5451,6 @@ "xpack.apm.transactionDurationLabel": "期間", "xpack.apm.transactionErrorRateAlert.name": "トランザクションエラー率しきい値", "xpack.apm.transactionErrorRateAlertTrigger.isAbove": "より大きい", - "xpack.apm.transactionOverview.userExperience.calloutText": "特にRUMサービスのユーザーエクスペリエンスメトリックを分析するための新しい経験が導入されます。ブラウザーと位置情報別に細分化されたコアバイタルとアクセスユーザーに関するインサイトを得ることができます。このアプリは、他のオブザーバビリティビューで左側のサイドバーで常に使用できます。", - "xpack.apm.transactionOverview.userExperience.calloutTitle": "導入:Elasticユーザーエクスペリエンス", - "xpack.apm.transactionOverview.userExperience.linkLabel": "移動", "xpack.apm.transactionRateLabel": "{value} tpm", "xpack.apm.transactions.latency.chart.95thPercentileLabel": "95 パーセンタイル", "xpack.apm.transactions.latency.chart.99thPercentileLabel": "99 パーセンタイル", @@ -13290,7 +13283,6 @@ "xpack.ml.jobsList.actionExecuteSuccessfullyNotificationMessage": "{successesJobsCount, plural, one{{successJob}} other{# 件のジョブ}} {actionTextPT}成功", "xpack.ml.jobsList.actionFailedNotificationMessage": "{failureId} が {actionText} に失敗しました", "xpack.ml.jobsList.actionsLabel": "アクション", - "xpack.ml.jobsList.analyticsSpacesLabel": "スペース", "xpack.ml.jobsList.auditMessageColumn.screenReaderDescription": "このカラムは、過去24時間にエラーまたは警告があった場合にアイコンを表示します", "xpack.ml.jobsList.breadcrumb": "ジョブ", "xpack.ml.jobsList.cannotSelectRowForJobMessage": "ジョブID {jobId}を選択できません", @@ -13493,19 +13485,6 @@ "xpack.ml.management.jobsList.noPermissionToAccessLabel": "ML ジョブへのアクセスにはパーミッションが必要です", "xpack.ml.management.jobsList.syncFlyoutButton": "保存されたオブジェクトを同期", "xpack.ml.management.jobsListTitle": "機械学習ジョブ", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text": "このジョブのスペースを変更するには、すべてのスペースでジョブを修正する権限が必要です。詳細については、システム管理者に連絡してください。", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title": "{jobId} のスペースを編集する権限が不十分です", - "xpack.ml.management.spacesSelectorFlyout.closeButton": "閉じる", - "xpack.ml.management.spacesSelectorFlyout.headerLabel": "{jobId} のスペースを選択", - "xpack.ml.management.spacesSelectorFlyout.saveButton": "保存", - "xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel": "スペースを選択", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text": "現在と将来のすべてのスペースでジョブを使用可能にします。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title": "すべてのスペース", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text": "選択したスペースでのみジョブを使用可能にします。", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title": "スペースを選択", - "xpack.ml.management.spacesSelectorFlyout.updateSpaces.error": "{id} の更新エラー", "xpack.ml.management.syncSavedObjectsFlyout.closeButton": "閉じる", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.description": "異常検知のデータフィード ID がない保存されたオブジェクトがある場合は、ID が追加されます。", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.title": "データフィードがない選択されたオブジェクト({count})", @@ -19512,9 +19491,7 @@ "xpack.securitySolution.timeline.eventsTableAriaLabel": "イベント; {activePage}/{totalPages} ページ", "xpack.securitySolution.timeline.expandableEvent.alertTitleLabel": "アラートの詳細", "xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel": "閉じる", - "xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip": "クリップボードにコピー", "xpack.securitySolution.timeline.expandableEvent.eventTitleLabel": "イベントの詳細", - "xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle": "イベント", "xpack.securitySolution.timeline.expandableEvent.messageTitle": "メッセージ", "xpack.securitySolution.timeline.expandableEvent.placeholder": "イベント詳細を表示するには、イベントを選択します", "xpack.securitySolution.timeline.fieldTooltip": "フィールド", @@ -20615,44 +20592,6 @@ "xpack.spaces.management.manageSpacePage.updateSpaceButton": "スペースを更新", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "リザーブされたスペースはビルトインのため、部分的な変更しかできません。", "xpack.spaces.management.selectAllFeaturesLink": "すべて選択", - "xpack.spaces.management.shareToSpace.actionDescription": "この保存されたオブジェクトを1つ以上のスペースと共有します。", - "xpack.spaces.management.shareToSpace.actionTitle": "スペースと共有", - "xpack.spaces.management.shareToSpace.allSpacesLabel": "*すべてのスペース", - "xpack.spaces.management.shareToSpace.cancelButton": "キャンセル", - "xpack.spaces.management.shareToSpace.columnDescription": "このオブジェクトが現在共有されている他のスペース", - "xpack.spaces.management.shareToSpace.columnTitle": "共有されているスペース", - "xpack.spaces.management.shareToSpace.columnUnauthorizedLabel": "これらのスペースを表示するアクセス権がありません。", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "新しいスペースを作成", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "オブジェクトを共有するには、{createANewSpaceLink}できます。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "このスペースの選択を解除するには、追加の権限が必要です。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "このスペースを選択するには、追加の権限が必要です。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "「{object}」は1つのスペースに追加されました。", - "xpack.spaces.management.shareToSpace.shareEditSuccessTitle": "オブジェクトが更新されました", - "xpack.spaces.management.shareToSpace.shareErrorTitle": "保存されたオブジェクトの更新エラー", - "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount}個が非表示", - "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount}個が選択済み", - "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle": "共有オプション", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text": "現在と将来のすべてのスペースでオブジェクトを使用可能にします。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "すべてのスペース", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "選択したスペースでのみオブジェクトを使用可能にします。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "スペースを選択", - "xpack.spaces.management.shareToSpace.shareNewSuccessTitle": "オブジェクトは共有されています", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースから削除されました。", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "「{object}」は1つのスペースから削除されました。", - "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存して閉じる", - "xpack.spaces.management.shareToSpace.shareWarningBody": "1つのスペースでのみ編集するには、{makeACopyLink}してください。", - "xpack.spaces.management.shareToSpace.shareWarningLink": "コピーを作成", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "共有オブジェクトの編集は、すべてのスペースで変更を適用します。", - "xpack.spaces.management.shareToSpace.showLessSpacesLink": "縮小表示", - "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "他 {count} 件", - "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "利用可能なスペースを読み込み中にエラーが発生", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "追加権限", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "非表示のスペースを表示するには、{additionalPrivilegesLink}が必要です。", - "xpack.spaces.management.shareToSpaceFlyoutHeader": "スペースと共有", "xpack.spaces.management.showAllFeaturesText": "すべて表示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[カスタマイズ]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "URL 識別子をカスタマイズ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d0dbd750853a25..a35d4c67dde001 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -660,11 +660,7 @@ "dashboard.topNav.saveModal.storeTimeWithDashboardFormRowHelpText": "每次加载此仪表板时,都会将时间筛选更改为当前选定的时间。", "dashboard.topNav.saveModal.storeTimeWithDashboardFormRowLabel": "将时间随仪表板保存", "dashboard.topNav.showCloneModal.dashboardCopyTitle": "{title} 副本", - "dashboard.topNave.addButtonAriaLabel": "库", - "dashboard.topNave.addConfigDescription": "将现有可视化添加到仪表板", "dashboard.topNave.cancelButtonAriaLabel": "取消", - "dashboard.topNave.addNewButtonAriaLabel": "创建面板", - "dashboard.topNave.addNewConfigDescription": "在此仪表板上创建新的面板", "dashboard.topNave.cloneButtonAriaLabel": "克隆", "dashboard.topNave.cloneConfigDescription": "创建仪表板的副本", "dashboard.topNave.editButtonAriaLabel": "编辑", @@ -5465,9 +5461,6 @@ "xpack.apm.transactionDurationLabel": "持续时间", "xpack.apm.transactionErrorRateAlert.name": "事务错误率阈值", "xpack.apm.transactionErrorRateAlertTrigger.isAbove": "高于", - "xpack.apm.transactionOverview.userExperience.calloutText": "我们非常高兴地推出一种用于分析用户体验指标的新体验,专门用于您的 RUM 服务。通过该体验,可深入了解按浏览器和位置细分的核心指标和访客。该应用始终位于其他“可观测性”视图的左侧边栏中。", - "xpack.apm.transactionOverview.userExperience.calloutTitle": "即将引入:Elastic 用户体验", - "xpack.apm.transactionOverview.userExperience.linkLabel": "带我前往此处", "xpack.apm.transactionRateLabel": "{value} tpm", "xpack.apm.transactions.latency.chart.95thPercentileLabel": "第 95 个百分位", "xpack.apm.transactions.latency.chart.99thPercentileLabel": "第 99 个百分位", @@ -13322,7 +13315,6 @@ "xpack.ml.jobsList.actionExecuteSuccessfullyNotificationMessage": "{successesJobsCount, plural, one{{successJob}} other{# 个作业}}{actionTextPT}已成功", "xpack.ml.jobsList.actionFailedNotificationMessage": "{failureId} 未能{actionText}", "xpack.ml.jobsList.actionsLabel": "操作", - "xpack.ml.jobsList.analyticsSpacesLabel": "工作区", "xpack.ml.jobsList.auditMessageColumn.screenReaderDescription": "过去 24 小时里该作业有错误或警告时,此列显示图标", "xpack.ml.jobsList.breadcrumb": "作业", "xpack.ml.jobsList.cannotSelectRowForJobMessage": "无法选择作业 ID {jobId}", @@ -13525,19 +13517,6 @@ "xpack.ml.management.jobsList.noPermissionToAccessLabel": "您需要访问 ML 作业的权限", "xpack.ml.management.jobsList.syncFlyoutButton": "同步已保存对象", "xpack.ml.management.jobsListTitle": "Machine Learning", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.text": "要更改此作业的工作区,您需要有在所有工作区中修改作业的权限。请与您的系统管理员联系,以获取更多信息。", - "xpack.ml.management.spacesSelectorFlyout.cannotEditCallout.title": "权限不足,无法编辑 {jobId} 的工作区", - "xpack.ml.management.spacesSelectorFlyout.closeButton": "关闭", - "xpack.ml.management.spacesSelectorFlyout.headerLabel": "为 {jobId} 选择工作区", - "xpack.ml.management.spacesSelectorFlyout.saveButton": "保存", - "xpack.ml.management.spacesSelectorFlyout.selectSpacesLabel": "选择工作区", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text": "使作业在所有当前和将来工作区中可用。", - "xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title": "所有工作区", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text": "使作业仅在选定工作区中可用。", - "xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title": "选择工作区", - "xpack.ml.management.spacesSelectorFlyout.updateSpaces.error": "更新 {id} 时出错", "xpack.ml.management.syncSavedObjectsFlyout.closeButton": "关闭", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.description": "如果有已保存对象缺失异常检测作业的数据馈送 ID,则将添加该 ID。", "xpack.ml.management.syncSavedObjectsFlyout.datafeedsAdded.title": "缺失数据馈送的已保存对象 ({count})", @@ -19558,9 +19537,7 @@ "xpack.securitySolution.timeline.eventsTableAriaLabel": "事件;第 {activePage} 页,共 {totalPages} 页", "xpack.securitySolution.timeline.expandableEvent.alertTitleLabel": "告警详情", "xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel": "关闭", - "xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip": "复制到剪贴板", "xpack.securitySolution.timeline.expandableEvent.eventTitleLabel": "事件详情", - "xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle": "事件", "xpack.securitySolution.timeline.expandableEvent.messageTitle": "消息", "xpack.securitySolution.timeline.expandableEvent.placeholder": "选择事件以显示事件详情", "xpack.securitySolution.timeline.fieldTooltip": "字段", @@ -20662,44 +20639,6 @@ "xpack.spaces.management.manageSpacePage.updateSpaceButton": "更新工作区", "xpack.spaces.management.reversedSpaceBadge.reversedSpacesCanBePartiallyModifiedTooltip": "保留的空间是内置的,只能进行部分修改。", "xpack.spaces.management.selectAllFeaturesLink": "全选", - "xpack.spaces.management.shareToSpace.actionDescription": "将此已保存对象共享到一个或多个工作区", - "xpack.spaces.management.shareToSpace.actionTitle": "共享到工作区", - "xpack.spaces.management.shareToSpace.allSpacesLabel": "* 所有工作区", - "xpack.spaces.management.shareToSpace.cancelButton": "取消", - "xpack.spaces.management.shareToSpace.columnDescription": "目前将此对象共享到的其他工作区", - "xpack.spaces.management.shareToSpace.columnTitle": "共享工作区", - "xpack.spaces.management.shareToSpace.columnUnauthorizedLabel": "您无权查看这些工作区。", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.linkText": "创建新工作区", - "xpack.spaces.management.shareToSpace.noAvailableSpaces.canCreateNewSpace.text": "您可以{createANewSpaceLink},用于共享您的对象。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.checked": "您需要额外权限才能取消选择此工作区。", - "xpack.spaces.management.shareToSpace.partiallyAuthorizedSpaceTooltip.unchecked": "您需要额外权限才能选择此工作区。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural": "“{object}”已添加到 {spaceTargets} 个工作区。", - "xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular": "“{object}”已添加到 1 个工作区。", - "xpack.spaces.management.shareToSpace.shareEditSuccessTitle": "对象已更新", - "xpack.spaces.management.shareToSpace.shareErrorTitle": "更新已保存对象时出错", - "xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount} 个已隐藏", - "xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount} 个已选择", - "xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel": "选择工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle": "共享选项", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text": "使对象在当前和将来的所有空间中可用。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title": "所有工作区", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "仅使对象在选定工作区中可用。", - "xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "选择工作区", - "xpack.spaces.management.shareToSpace.shareNewSuccessTitle": "对象现已共享", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural": "“{object}”已从 {spaceTargets} 个工作区中移除。", - "xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular": "“{object}”已从 1 个工作区中移除。", - "xpack.spaces.management.shareToSpace.shareToSpacesButton": "保存并关闭", - "xpack.spaces.management.shareToSpace.shareWarningBody": "要仅在一个工作区中编辑,请改为{makeACopyLink}。", - "xpack.spaces.management.shareToSpace.shareWarningLink": "创建副本", - "xpack.spaces.management.shareToSpace.shareWarningTitle": "编辑共享对象会在所有工作区中应用更改", - "xpack.spaces.management.shareToSpace.showLessSpacesLink": "显示更少", - "xpack.spaces.management.shareToSpace.showMoreSpacesLink": "另外 {count} 个", - "xpack.spaces.management.shareToSpace.spacesLoadErrorTitle": "加载可用工作区时出错", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.additionalPrivilegesLink": "其他权限", - "xpack.spaces.management.shareToSpace.unknownSpacesLabel.text": "要查看隐藏的工作区,您需要{additionalPrivilegesLink}。", - "xpack.spaces.management.shareToSpaceFlyoutHeader": "共享到工作区", "xpack.spaces.management.showAllFeaturesText": "全部显示", "xpack.spaces.management.spaceIdentifier.customizeSpaceLinkText": "[定制]", "xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel": "定制 URL 标识符", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts new file mode 100644 index 00000000000000..314d2244911288 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSelectOption } from '@elastic/eui'; +import { Choice } from './types'; + +export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index bfc32ef67e46f0..e864a8d3fd114f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -28,6 +28,8 @@ const actionParams = { severity: '1', urgency: '2', impact: '3', + category: 'software', + subcategory: 'os', externalId: null, }, comments: [], @@ -55,34 +57,48 @@ const defaultProps = { const useGetChoicesResponse = { isLoading: false, - choices: ['severity', 'urgency', 'impact'] - .map((element) => [ - { - dependent_value: '', - label: '1 - Critical', - value: '1', - element, - }, - { - dependent_value: '', - label: '2 - High', - value: '2', - element, - }, - { - dependent_value: '', - label: '3 - Moderate', - value: '3', - element, - }, - { - dependent_value: '', - label: '4 - Low', - value: '4', - element, - }, - ]) - .flat(), + choices: [ + { + dependent_value: '', + label: 'Software', + value: 'software', + element: 'category', + }, + { + dependent_value: 'software', + label: 'Operation System', + value: 'os', + element: 'subcategory', + }, + ...['severity', 'urgency', 'impact'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), + ], }; describe('ServiceNowITSMParamsFields renders', () => { @@ -101,6 +117,8 @@ describe('ServiceNowITSMParamsFields renders', () => { expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); @@ -153,6 +171,36 @@ describe('ServiceNowITSMParamsFields renders', () => { }); }); + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { + value: 'software', + text: 'Software', + }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoices(useGetChoicesResponse.choices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Operation System', + value: 'os', + }, + ]); + }); + test('it transforms the options correctly', async () => { const wrapper = mount(); act(() => { @@ -179,6 +227,8 @@ describe('ServiceNowITSMParamsFields renders', () => { { dataTestSubj: '[data-test-subj="urgencySelect"]', key: 'urgency' }, { dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' }, { dataTestSubj: '[data-test-subj="impactSelect"]', key: 'impact' }, + { dataTestSubj: '[data-test-subj="categorySelect"]', key: 'category' }, + { dataTestSubj: '[data-test-subj="subcategorySelect"]', key: 'subcategory' }, ]; simpleFields.forEach((field) => diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index 3befa232e5b521..84326a7ae9be8b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -16,17 +16,22 @@ import { } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; -import { ServiceNowITSMActionParams, Choice, Options } from './types'; +import { ServiceNowITSMActionParams, Choice, Fields } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; import { useGetChoices } from './use_get_choices'; +import { choicesToEuiOptions } from './helpers'; + import * as i18n from './translations'; -const useGetChoicesFields = ['urgency', 'severity', 'impact']; -const defaultOptions: Options = { +const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; +const defaultFields: Fields = { + category: [], + subcategory: [], urgency: [], severity: [], impact: [], + priority: [], }; const ServiceNowParamsFields: React.FunctionComponent< @@ -48,7 +53,7 @@ const ServiceNowParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); - const [options, setOptions] = useState(defaultOptions); + const [choices, setChoices] = useState(defaultFields); const editSubActionProperty = useCallback( (key: string, value: any) => { @@ -73,19 +78,32 @@ const ServiceNowParamsFields: React.FunctionComponent< [editSubActionProperty] ); - const onChoicesSuccess = (choices: Choice[]) => - setOptions( - choices.reduce( - (acc, choice) => ({ + const onChoicesSuccess = useCallback((values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ ...acc, - [choice.element]: [ - ...(acc[choice.element] != null ? acc[choice.element] : []), - { value: choice.value, text: choice.label }, - ], + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], }), - defaultOptions + defaultFields ) ); + }, []); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); + const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); + const impactOptions = useMemo(() => choicesToEuiOptions(choices.impact), [choices.impact]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter( + (subcategory) => subcategory.dependent_value === incident.category + ) + ), + [choices.subcategory, incident.category] + ); const { isLoading: isLoadingChoices } = useGetChoices({ http, @@ -140,7 +158,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.urgency} + options={urgencyOptions} value={incident.urgency ?? ''} onChange={(e) => editSubActionProperty('urgency', e.target.value)} /> @@ -155,7 +173,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.severity} + options={severityOptions} value={incident.severity ?? ''} onChange={(e) => editSubActionProperty('severity', e.target.value)} /> @@ -169,7 +187,7 @@ const ServiceNowParamsFields: React.FunctionComponent< hasNoInitialSelection isLoading={isLoadingChoices} disabled={isLoadingChoices} - options={options.impact} + options={impactOptions} value={incident.impact ?? ''} onChange={(e) => editSubActionProperty('impact', e.target.value)} /> @@ -177,6 +195,47 @@ const ServiceNowParamsFields: React.FunctionComponent< + + + + { + editAction( + 'subActionParams', + { + incident: { ...incident, category: e.target.value, subcategory: null }, + comments, + }, + index + ); + }} + /> + + + + + editSubActionProperty('subcategory', e.target.value)} + /> + + + + - choices.map((choice) => ({ value: choice.value, text: choice.label })); - const ServiceNowSIRParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { @@ -218,16 +215,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< disabled={isLoadingChoices} options={priorityOptions} value={incident.priority ?? undefined} - onChange={(e) => { - editAction( - 'subActionParams', - { - incident: { ...incident, priority: e.target.value }, - comments, - }, - index - ); - }} + onChange={(e) => editSubActionProperty('priority', e.target.value)} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index 09f27c92e80820..f252f4648e670e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { EuiSelectOption } from '@elastic/eui'; import { UserConfiguredActionConnector } from '../../../../types'; import { ExecutorSubActionPushParamsITSM, @@ -45,4 +44,3 @@ export interface Choice { } export type Fields = Record; -export type Options = Record; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 7358facf215c34..06eaa8285991cf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -591,6 +591,7 @@ export const AlertForm = ({ alertParams={alert.params} alertInterval={`${alertInterval ?? 1}${alertIntervalUnit}`} alertThrottle={`${alertThrottle ?? 1}${alertThrottleUnit}`} + alertNotifyWhen={alert.notifyWhen ?? 'onActionGroupChange'} errors={errors} setAlertParams={setAlertParams} setAlertProperty={setAlertProperty} @@ -768,6 +769,7 @@ export const AlertForm = ({ setAlertIntervalUnit(e.target.value); setScheduleProperty('interval', `${alertInterval}${e.target.value}`); }} + data-test-subj="intervalInputUnit" /> diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 6fb52cf1151d56..3e41d27596c34e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -198,6 +198,7 @@ export interface AlertTypeParamsExpressionProps< alertParams: Params; alertInterval: string; alertThrottle: string; + alertNotifyWhen: AlertNotifyWhenType; setAlertParams: (property: Key, value: Params[Key] | undefined) => void; setAlertProperty: ( key: Prop, diff --git a/x-pack/plugins/uptime/common/runtime_types/network_events.ts b/x-pack/plugins/uptime/common/runtime_types/network_events.ts index 668e17d2a848bb..7b651b6a919514 100644 --- a/x-pack/plugins/uptime/common/runtime_types/network_events.ts +++ b/x-pack/plugins/uptime/common/runtime_types/network_events.ts @@ -20,24 +20,36 @@ const NetworkTimingsType = t.type({ ssl: t.number, }); -export type NetworkTimings = t.TypeOf; +const CertificateDataType = t.partial({ + validFrom: t.number, + validTo: t.number, + issuer: t.string, + subjectName: t.string, +}); const NetworkEventType = t.intersection([ t.type({ timestamp: t.string, requestSentTime: t.number, loadEndTime: t.number, + url: t.string, }), t.partial({ + bytesDownloadedCompressed: t.number, + certificates: CertificateDataType, + ip: t.string, method: t.string, - url: t.string, status: t.number, mimeType: t.string, requestStartTime: t.number, + responseHeaders: t.record(t.string, t.string), + requestHeaders: t.record(t.string, t.string), timings: NetworkTimingsType, }), ]); +export type NetworkTimings = t.TypeOf; +export type CertificateData = t.TypeOf; export type NetworkEvent = t.TypeOf; export const SyntheticsNetworkEventsApiResponseType = t.type({ diff --git a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap index 967d078bde2105..238ce6c3f9ceec 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__snapshots__/donut_chart.test.tsx.snap @@ -39,6 +39,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": false, }, @@ -134,6 +135,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": true, }, @@ -170,12 +172,17 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "#F5F5F5", "visible": true, }, - "line": Object { + "crossLine": Object { "dash": Array [ 5, 5, ], - "stroke": "#777", + "stroke": "#98A2B3", + "strokeWidth": 1, + "visible": true, + }, + "line": Object { + "stroke": "#98A2B3", "strokeWidth": 1, "visible": true, }, @@ -196,6 +203,7 @@ exports[`DonutChart component passes correct props without errors for valid prop "fill": "white", "opacity": 1, "radius": 2, + "shape": "circle", "strokeWidth": 1, "visible": true, }, diff --git a/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx b/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx index 2c6ad63b51e7b2..3d0fefbd083f81 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/duration_line_bar_list.tsx @@ -7,8 +7,7 @@ import React from 'react'; import moment from 'moment'; -import { AnnotationTooltipFormatter, RectAnnotation } from '@elastic/charts'; -import { RectAnnotationDatum } from '@elastic/charts/dist/chart_types/xy_chart/utils/specs'; +import { AnnotationTooltipFormatter, RectAnnotation, RectAnnotationDatum } from '@elastic/charts'; import { AnnotationTooltip } from './annotation_tooltip'; import { ANOMALY_SEVERITY, diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx index b96b61d8743300..bf5b0215e7d7ab 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { PingList } from './ping_list'; +import { formatDuration, PingList } from './ping_list'; import { Ping, PingsResponse } from '../../../../common/runtime_types'; import { ExpandedRowMap } from '../../overview/monitor_list/types'; import { rowShouldExpand, toggleDetails } from './columns/expand_row'; @@ -185,5 +185,23 @@ describe('PingList component', () => { expect(rowShouldExpand(ping)).toBe(true); }); }); + + describe('formatDuration', () => { + it('returns zero for < 1 millisecond', () => { + expect(formatDuration(984)).toBe('0 ms'); + }); + + it('returns milliseconds string if < 1 seconds', () => { + expect(formatDuration(921_039)).toBe('921 ms'); + }); + + it('returns seconds string if > 1 second', () => { + expect(formatDuration(1_032_100)).toBe('1 second'); + }); + + it('rounds to closest second', () => { + expect(formatDuration(1_832_100)).toBe('2 seconds'); + }); + }); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index 110c46eca31d18..18bc5f5ec3ecbf 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -34,6 +34,35 @@ export const SpanWithMargin = styled.span` const DEFAULT_PAGE_SIZE = 10; +// one second = 1 million micros +const ONE_SECOND_AS_MICROS = 1000000; + +// the limit for converting to seconds is >= 1 sec +const MILLIS_LIMIT = ONE_SECOND_AS_MICROS * 1; + +export const formatDuration = (durationMicros: number) => { + if (durationMicros < MILLIS_LIMIT) { + return i18n.translate('xpack.uptime.pingList.durationMsColumnFormatting', { + values: { millis: microsToMillis(durationMicros) }, + defaultMessage: '{millis} ms', + }); + } + const seconds = (durationMicros / ONE_SECOND_AS_MICROS).toFixed(0); + + // we format seconds with correct pulralization here and not for `ms` because it is much more likely users + // will encounter times of exactly '1' second. + if (seconds === '1') { + return i18n.translate('xpack.uptime.pingist.durationSecondsColumnFormatting.singular', { + values: { seconds }, + defaultMessage: '{seconds} second', + }); + } + return i18n.translate('xpack.uptime.pingist.durationSecondsColumnFormatting', { + values: { seconds }, + defaultMessage: '{seconds} seconds', + }); +}; + export const PingList = () => { const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageIndex, setPageIndex] = useState(0); @@ -135,11 +164,7 @@ export const PingList = () => { name: i18n.translate('xpack.uptime.pingList.durationMsColumnLabel', { defaultMessage: 'Duration', }), - render: (duration: number) => - i18n.translate('xpack.uptime.pingList.durationMsColumnFormatting', { - values: { millis: microsToMillis(duration) }, - defaultMessage: '{millis} ms', - }), + render: (duration: number) => formatDuration(duration), }, { field: 'error.type', diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts index a02116877f49a4..9376a83f48b3d5 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts @@ -4,12 +4,25 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { colourPalette, getSeriesAndDomain, getSidebarItems } from './data_formatting'; -import { NetworkItems, MimeType } from './types'; +import moment from 'moment'; +import { + colourPalette, + getConnectingTime, + getSeriesAndDomain, + getSidebarItems, +} from './data_formatting'; +import { + NetworkItems, + MimeType, + FriendlyFlyoutLabels, + FriendlyTimingLabels, + Timings, + Metadata, +} from './types'; +import { mockMoment } from '../../../../../lib/helper/test_helpers'; import { WaterfallDataEntry } from '../../waterfall/types'; -const networkItems: NetworkItems = [ +export const networkItems: NetworkItems = [ { timestamp: '2021-01-05T19:22:28.928Z', method: 'GET', @@ -31,6 +44,20 @@ const networkItems: NetworkItems = [ ssl: 55.38700000033714, dns: 3.559999997378327, }, + bytesDownloadedCompressed: 1000, + requestHeaders: { + sample_request_header: 'sample request header', + }, + responseHeaders: { + sample_response_header: 'sample response header', + }, + certificates: { + issuer: 'Sample Issuer', + validFrom: 1578441600000, + validTo: 1617883200000, + subjectName: '*.elastic.co', + }, + ip: '104.18.8.22', }, { timestamp: '2021-01-05T19:22:28.928Z', @@ -56,7 +83,7 @@ const networkItems: NetworkItems = [ }, ]; -const networkItemsWithoutFullTimings: NetworkItems = [ +export const networkItemsWithoutFullTimings: NetworkItems = [ networkItems[0], { timestamp: '2021-01-05T19:22:28.928Z', @@ -81,7 +108,7 @@ const networkItemsWithoutFullTimings: NetworkItems = [ }, ]; -const networkItemsWithoutAnyTimings: NetworkItems = [ +export const networkItemsWithoutAnyTimings: NetworkItems = [ { timestamp: '2021-01-05T19:22:28.928Z', method: 'GET', @@ -105,7 +132,7 @@ const networkItemsWithoutAnyTimings: NetworkItems = [ }, ]; -const networkItemsWithoutTimingsObject: NetworkItems = [ +export const networkItemsWithoutTimingsObject: NetworkItems = [ { timestamp: '2021-01-05T19:22:28.928Z', method: 'GET', @@ -117,7 +144,7 @@ const networkItemsWithoutTimingsObject: NetworkItems = [ }, ]; -const networkItemsWithUncommonMimeType: NetworkItems = [ +export const networkItemsWithUncommonMimeType: NetworkItems = [ { timestamp: '2021-01-05T19:22:28.928Z', method: 'GET', @@ -142,6 +169,28 @@ const networkItemsWithUncommonMimeType: NetworkItems = [ }, ]; +describe('getConnectingTime', () => { + it('returns `connect` value if `ssl` is undefined', () => { + expect(getConnectingTime(10)).toBe(10); + }); + + it('returns `undefined` if `connect` is not defined', () => { + expect(getConnectingTime(undefined, 23)).toBeUndefined(); + }); + + it('returns `connect` value if `ssl` is 0', () => { + expect(getConnectingTime(10, 0)).toBe(10); + }); + + it('returns `connect` value if `ssl` is -1', () => { + expect(getConnectingTime(10, 0)).toBe(10); + }); + + it('reduces `connect` value by `ssl` value if both are defined', () => { + expect(getConnectingTime(10, 3)).toBe(7); + }); +}); + describe('Palettes', () => { it('A colour palette comprising timing and mime type colours is correctly generated', () => { expect(colourPalette).toEqual({ @@ -163,299 +212,326 @@ describe('Palettes', () => { }); describe('getSeriesAndDomain', () => { - it('formats timings', () => { + beforeEach(() => { + mockMoment(); + }); + + it('formats series timings', () => { const actual = getSeriesAndDomain(networkItems); - expect(actual).toMatchInlineSnapshot(` - Object { - "domain": Object { - "max": 140.7760000010603, - "min": 0, - }, - "series": Array [ - Object { - "config": Object { + expect(actual.series).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "colour": "#dcd4c4", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#dcd4c4", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#dcd4c4", - "value": "Queued / Blocked: 0.854ms", - }, + "value": "Queued / Blocked: 0.854ms", }, - "x": 0, - "y": 0.8540000017092098, - "y0": 0, }, - Object { - "config": Object { + "x": 0, + "y": 0.8540000017092098, + "y0": 0, + }, + Object { + "config": Object { + "colour": "#54b399", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#54b399", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#54b399", - "value": "DNS: 3.560ms", - }, + "value": "DNS: 3.560ms", }, - "x": 0, - "y": 4.413999999087537, - "y0": 0.8540000017092098, }, - Object { - "config": Object { + "x": 0, + "y": 4.413999999087537, + "y0": 0.8540000017092098, + }, + Object { + "config": Object { + "colour": "#da8b45", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#da8b45", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#da8b45", - "value": "Connecting: 25.721ms", - }, + "value": "Connecting: 25.721ms", }, - "x": 0, - "y": 30.135000000882428, - "y0": 4.413999999087537, }, - Object { - "config": Object { + "x": 0, + "y": 30.135000000882428, + "y0": 4.413999999087537, + }, + Object { + "config": Object { + "colour": "#edc5a2", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#edc5a2", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#edc5a2", - "value": "TLS: 55.387ms", - }, + "value": "TLS: 55.387ms", }, - "x": 0, - "y": 85.52200000121957, - "y0": 30.135000000882428, }, - Object { - "config": Object { + "x": 0, + "y": 85.52200000121957, + "y0": 30.135000000882428, + }, + Object { + "config": Object { + "colour": "#d36086", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#d36086", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#d36086", - "value": "Sending request: 0.360ms", - }, + "value": "Sending request: 0.360ms", }, - "x": 0, - "y": 85.88200000303914, - "y0": 85.52200000121957, }, - Object { - "config": Object { + "x": 0, + "y": 85.88200000303914, + "y0": 85.52200000121957, + }, + Object { + "config": Object { + "colour": "#b0c9e0", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#b0c9e0", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#b0c9e0", - "value": "Waiting (TTFB): 34.578ms", - }, + "value": "Waiting (TTFB): 34.578ms", }, - "x": 0, - "y": 120.4600000019127, - "y0": 85.88200000303914, }, - Object { - "config": Object { + "x": 0, + "y": 120.4600000019127, + "y0": 85.88200000303914, + }, + Object { + "config": Object { + "colour": "#ca8eae", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#ca8eae", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#ca8eae", - "value": "Content downloading (CSS): 0.552ms", - }, + "value": "Content downloading (CSS): 0.552ms", }, - "x": 0, - "y": 121.01200000324752, - "y0": 120.4600000019127, }, - Object { - "config": Object { + "x": 0, + "y": 121.01200000324752, + "y0": 120.4600000019127, + }, + Object { + "config": Object { + "colour": "#dcd4c4", + "id": 1, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#dcd4c4", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#dcd4c4", - "value": "Queued / Blocked: 84.546ms", - }, + "value": "Queued / Blocked: 84.546ms", }, - "x": 1, - "y": 84.90799999795854, - "y0": 0.3619999997317791, }, - Object { - "config": Object { + "x": 1, + "y": 84.90799999795854, + "y0": 0.3619999997317791, + }, + Object { + "config": Object { + "colour": "#d36086", + "id": 1, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#d36086", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#d36086", - "value": "Sending request: 0.239ms", - }, + "value": "Sending request: 0.239ms", }, - "x": 1, - "y": 85.14699999883305, - "y0": 84.90799999795854, }, - Object { - "config": Object { + "x": 1, + "y": 85.14699999883305, + "y0": 84.90799999795854, + }, + Object { + "config": Object { + "colour": "#b0c9e0", + "id": 1, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#b0c9e0", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#b0c9e0", - "value": "Waiting (TTFB): 52.561ms", - }, + "value": "Waiting (TTFB): 52.561ms", }, - "x": 1, - "y": 137.70799999925657, - "y0": 85.14699999883305, }, - Object { - "config": Object { + "x": 1, + "y": 137.70799999925657, + "y0": 85.14699999883305, + }, + Object { + "config": Object { + "colour": "#9170b8", + "id": 1, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#9170b8", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#9170b8", - "value": "Content downloading (JS): 3.068ms", - }, + "value": "Content downloading (JS): 3.068ms", }, - "x": 1, - "y": 140.7760000010603, - "y0": 137.70799999925657, }, - ], - "totalHighlightedRequests": 2, - } + "x": 1, + "y": 140.7760000010603, + "y0": 137.70799999925657, + }, + ] `); }); - it('handles formatting when only total timing values are available', () => { - const actual = getSeriesAndDomain(networkItemsWithoutFullTimings); - expect(actual).toMatchInlineSnapshot(` - Object { - "domain": Object { - "max": 121.01200000324752, - "min": 0, - }, - "series": Array [ - Object { - "config": Object { + it('handles series formatting when only total timing values are available', () => { + const { series } = getSeriesAndDomain(networkItemsWithoutFullTimings); + expect(series).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "colour": "#dcd4c4", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#dcd4c4", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#dcd4c4", - "value": "Queued / Blocked: 0.854ms", - }, + "value": "Queued / Blocked: 0.854ms", }, - "x": 0, - "y": 0.8540000017092098, - "y0": 0, }, - Object { - "config": Object { + "x": 0, + "y": 0.8540000017092098, + "y0": 0, + }, + Object { + "config": Object { + "colour": "#54b399", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#54b399", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#54b399", - "value": "DNS: 3.560ms", - }, + "value": "DNS: 3.560ms", }, - "x": 0, - "y": 4.413999999087537, - "y0": 0.8540000017092098, }, - Object { - "config": Object { + "x": 0, + "y": 4.413999999087537, + "y0": 0.8540000017092098, + }, + Object { + "config": Object { + "colour": "#da8b45", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#da8b45", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#da8b45", - "value": "Connecting: 25.721ms", - }, + "value": "Connecting: 25.721ms", }, - "x": 0, - "y": 30.135000000882428, - "y0": 4.413999999087537, }, - Object { - "config": Object { + "x": 0, + "y": 30.135000000882428, + "y0": 4.413999999087537, + }, + Object { + "config": Object { + "colour": "#edc5a2", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#edc5a2", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#edc5a2", - "value": "TLS: 55.387ms", - }, + "value": "TLS: 55.387ms", }, - "x": 0, - "y": 85.52200000121957, - "y0": 30.135000000882428, }, - Object { - "config": Object { + "x": 0, + "y": 85.52200000121957, + "y0": 30.135000000882428, + }, + Object { + "config": Object { + "colour": "#d36086", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#d36086", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#d36086", - "value": "Sending request: 0.360ms", - }, + "value": "Sending request: 0.360ms", }, - "x": 0, - "y": 85.88200000303914, - "y0": 85.52200000121957, }, - Object { - "config": Object { + "x": 0, + "y": 85.88200000303914, + "y0": 85.52200000121957, + }, + Object { + "config": Object { + "colour": "#b0c9e0", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#b0c9e0", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#b0c9e0", - "value": "Waiting (TTFB): 34.578ms", - }, + "value": "Waiting (TTFB): 34.578ms", }, - "x": 0, - "y": 120.4600000019127, - "y0": 85.88200000303914, }, - Object { - "config": Object { + "x": 0, + "y": 120.4600000019127, + "y0": 85.88200000303914, + }, + Object { + "config": Object { + "colour": "#ca8eae", + "id": 0, + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#ca8eae", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#ca8eae", - "value": "Content downloading (CSS): 0.552ms", - }, + "value": "Content downloading (CSS): 0.552ms", }, - "x": 0, - "y": 121.01200000324752, - "y0": 120.4600000019127, }, - Object { - "config": Object { + "x": 0, + "y": 121.01200000324752, + "y0": 120.4600000019127, + }, + Object { + "config": Object { + "colour": "#9170b8", + "isHighlighted": true, + "showTooltip": true, + "tooltipProps": Object { "colour": "#9170b8", - "isHighlighted": true, - "showTooltip": true, - "tooltipProps": Object { - "colour": "#9170b8", - "value": "Content downloading (JS): 2.793ms", - }, + "value": "Content downloading (JS): 2.793ms", }, - "x": 1, - "y": 3.714999998046551, - "y0": 0.9219999983906746, }, - ], - "totalHighlightedRequests": 2, - } + "x": 1, + "y": 3.714999998046551, + "y0": 0.9219999983906746, + }, + ] + `); + }); + + it('handles series formatting when there is no timing information available', () => { + const { series } = getSeriesAndDomain(networkItemsWithoutAnyTimings); + expect(series).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "colour": "", + "isHighlighted": true, + "showTooltip": false, + "tooltipProps": undefined, + }, + "x": 0, + "y": 0, + "y0": 0, + }, + ] `); }); @@ -467,6 +543,53 @@ describe('getSeriesAndDomain', () => { "max": 0, "min": 0, }, + "metadata": Array [ + Object { + "certificates": undefined, + "details": Array [ + Object { + "name": "Content type", + "value": "text/javascript", + }, + Object { + "name": "Request start", + "value": "0.000 ms", + }, + Object { + "name": "DNS", + "value": undefined, + }, + Object { + "name": "Connecting", + "value": undefined, + }, + Object { + "name": "TLS", + "value": undefined, + }, + Object { + "name": "Waiting (TTFB)", + "value": undefined, + }, + Object { + "name": "Content downloading", + "value": undefined, + }, + Object { + "name": "Bytes downloaded (compressed)", + "value": undefined, + }, + Object { + "name": "IP", + "value": undefined, + }, + ], + "requestHeaders": undefined, + "responseHeaders": undefined, + "url": "file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js", + "x": 0, + }, + ], "series": Array [ Object { "config": Object { @@ -486,32 +609,24 @@ describe('getSeriesAndDomain', () => { }); it('handles formatting when the timings object is undefined', () => { - const actual = getSeriesAndDomain(networkItemsWithoutTimingsObject); - expect(actual).toMatchInlineSnapshot(` - Object { - "domain": Object { - "max": 0, - "min": 0, - }, - "series": Array [ - Object { - "config": Object { - "isHighlighted": true, - "showTooltip": false, - }, - "x": 0, - "y": 0, - "y0": 0, + const { series } = getSeriesAndDomain(networkItemsWithoutTimingsObject); + expect(series).toMatchInlineSnapshot(` + Array [ + Object { + "config": Object { + "isHighlighted": true, + "showTooltip": false, }, - ], - "totalHighlightedRequests": 1, - } + "x": 0, + "y": 0, + "y0": 0, + }, + ] `); }); it('handles formatting when mime type is not mapped to a specific mime type bucket', () => { - const actual = getSeriesAndDomain(networkItemsWithUncommonMimeType); - const { series } = actual; + const { series } = getSeriesAndDomain(networkItemsWithUncommonMimeType); /* verify that raw mime type appears in the tooltip config and that * the colour is mapped to mime type other */ const contentDownloadedingConfigItem = series.find((item: WaterfallDataEntry) => { @@ -527,6 +642,48 @@ describe('getSeriesAndDomain', () => { expect(contentDownloadedingConfigItem).toBeDefined(); }); + it.each([ + [FriendlyFlyoutLabels[Metadata.MimeType], 'text/css'], + [FriendlyFlyoutLabels[Metadata.RequestStart], '0.000 ms'], + [FriendlyTimingLabels[Timings.Dns], '3.560 ms'], + [FriendlyTimingLabels[Timings.Connect], '25.721 ms'], + [FriendlyTimingLabels[Timings.Ssl], '55.387 ms'], + [FriendlyTimingLabels[Timings.Wait], '34.578 ms'], + [FriendlyTimingLabels[Timings.Receive], '0.552 ms'], + [FriendlyFlyoutLabels[Metadata.BytesDownloadedCompressed], '1.000 KB'], + [FriendlyFlyoutLabels[Metadata.IP], '104.18.8.22'], + ])('handles metadata details formatting', (name, value) => { + const { metadata } = getSeriesAndDomain(networkItems); + const metadataEntry = metadata[0]; + expect( + metadataEntry.details.find((item) => item.value === value && item.name === name) + ).toBeDefined(); + }); + + it('handles metadata headers formatting', () => { + const { metadata } = getSeriesAndDomain(networkItems); + const metadataEntry = metadata[0]; + metadataEntry.requestHeaders?.forEach((header) => { + expect(header).toEqual({ name: header.name, value: header.value }); + }); + metadataEntry.responseHeaders?.forEach((header) => { + expect(header).toEqual({ name: header.name, value: header.value }); + }); + }); + + it('handles certificate formatting', () => { + const { metadata } = getSeriesAndDomain([networkItems[0]]); + const metadataEntry = metadata[0]; + expect(metadataEntry.certificates).toEqual([ + { name: 'Issuer', value: networkItems[0].certificates?.issuer }, + { name: 'Valid from', value: moment(networkItems[0].certificates?.validFrom).format('L LT') }, + { name: 'Valid until', value: moment(networkItems[0].certificates?.validTo).format('L LT') }, + { name: 'Common name', value: networkItems[0].certificates?.subjectName }, + ]); + metadataEntry.responseHeaders?.forEach((header) => { + expect(header).toEqual({ name: header.name, value: header.value }); + }); + }); it('counts the total number of highlighted items', () => { // only one CSS file in this array of network Items const actual = getSeriesAndDomain(networkItems, false, '', ['stylesheet']); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts index 46f0d23d0a6b99..23d9b2d8563ae3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts @@ -6,20 +6,23 @@ */ import { euiPaletteColorBlind } from '@elastic/eui'; +import moment from 'moment'; import { NetworkItems, NetworkItem, + FriendlyFlyoutLabels, FriendlyTimingLabels, FriendlyMimetypeLabels, MimeType, MimeTypesMap, Timings, + Metadata, TIMING_ORDER, SidebarItems, LegendItems, } from './types'; -import { WaterfallData } from '../../waterfall'; +import { WaterfallData, WaterfallMetadata } from '../../waterfall'; import { NetworkEvent } from '../../../../../../common/runtime_types'; export const extractItems = (data: NetworkEvent[]): NetworkItems => { @@ -71,6 +74,29 @@ export const isHighlightedItem = ( return !!(matchQuery && matchFilters); }; +const getFriendlyMetadataValue = ({ value, postFix }: { value?: number; postFix?: string }) => { + // value === -1 indicates timing data cannot be extracted + if (value === undefined || value === -1) { + return undefined; + } + + let formattedValue = formatValueForDisplay(value); + + if (postFix) { + formattedValue = `${formattedValue} ${postFix}`; + } + + return formattedValue; +}; + +export const getConnectingTime = (connect?: number, ssl?: number) => { + if (ssl && connect && ssl > 0) { + return connect - ssl; + } else { + return connect; + } +}; + export const getSeriesAndDomain = ( items: NetworkItems, onlyHighlighted = false, @@ -80,34 +106,36 @@ export const getSeriesAndDomain = ( const getValueForOffset = (item: NetworkItem) => { return item.requestSentTime; }; - // The earliest point in time a request is sent or started. This will become our notion of "0". - const zeroOffset = items.reduce((acc, item) => { - const offsetValue = getValueForOffset(item); - return offsetValue < acc ? offsetValue : acc; - }, Infinity); + let zeroOffset = Infinity; + items.forEach((i) => (zeroOffset = Math.min(zeroOffset, getValueForOffset(i)))); const getValue = (timings: NetworkEvent['timings'], timing: Timings) => { if (!timings) return; // SSL is a part of the connect timing - if (timing === Timings.Connect && timings.ssl > 0) { - return timings.connect - timings.ssl; - } else { - return timings[timing]; + if (timing === Timings.Connect) { + return getConnectingTime(timings.connect, timings.ssl); } + return timings[timing]; }; + const series: WaterfallData = []; + const metadata: WaterfallMetadata = []; let totalHighlightedRequests = 0; - const series = items.reduce((acc, item, index) => { + items.forEach((item, index) => { + const mimeTypeColour = getColourForMimeType(item.mimeType); + const offsetValue = getValueForOffset(item); + let currentOffset = offsetValue - zeroOffset; + metadata.push(formatMetadata({ item, index, requestStart: currentOffset })); const isHighlighted = isHighlightedItem(item, query, activeFilters); if (isHighlighted) { totalHighlightedRequests++; } if (!item.timings) { - acc.push({ + series.push({ x: index, y0: 0, y: 0, @@ -116,14 +144,9 @@ export const getSeriesAndDomain = ( showTooltip: false, }, }); - return acc; + return; } - const offsetValue = getValueForOffset(item); - const mimeTypeColour = getColourForMimeType(item.mimeType); - - let currentOffset = offsetValue - zeroOffset; - let timingValueFound = false; TIMING_ORDER.forEach((timing) => { @@ -133,11 +156,12 @@ export const getSeriesAndDomain = ( const colour = timing === Timings.Receive ? mimeTypeColour : colourPalette[timing]; const y = currentOffset + value; - acc.push({ + series.push({ x: index, y0: currentOffset, y, config: { + id: index, colour, isHighlighted, showTooltip: true, @@ -161,7 +185,7 @@ export const getSeriesAndDomain = ( if (!timingValueFound) { const total = item.timings.total; const hasTotal = total !== -1; - acc.push({ + series.push({ x: index, y0: hasTotal ? currentOffset : 0, y: hasTotal ? currentOffset + item.timings.total : 0, @@ -182,8 +206,7 @@ export const getSeriesAndDomain = ( }, }); } - return acc; - }, []); + }); const yValues = series.map((serie) => serie.y); const domain = { min: 0, max: Math.max(...yValues) }; @@ -193,7 +216,108 @@ export const getSeriesAndDomain = ( filteredSeries = series.filter((item) => item.config.isHighlighted); } - return { series: filteredSeries, domain, totalHighlightedRequests }; + return { series: filteredSeries, domain, metadata, totalHighlightedRequests }; +}; + +const formatHeaders = (headers?: Record) => { + if (typeof headers === 'undefined') { + return undefined; + } + return Object.keys(headers).map((key) => ({ + name: key, + value: `${headers[key]}`, + })); +}; + +const formatMetadata = ({ + item, + index, + requestStart, +}: { + item: NetworkItem; + index: number; + requestStart: number; +}) => { + const { + bytesDownloadedCompressed, + certificates, + ip, + mimeType, + requestHeaders, + responseHeaders, + url, + } = item; + const { dns, connect, ssl, wait, receive, total } = item.timings || {}; + const contentDownloaded = receive && receive > 0 ? receive : total; + return { + x: index, + url, + requestHeaders: formatHeaders(requestHeaders), + responseHeaders: formatHeaders(responseHeaders), + certificates: certificates + ? [ + { + name: FriendlyFlyoutLabels[Metadata.CertificateIssuer], + value: certificates.issuer, + }, + { + name: FriendlyFlyoutLabels[Metadata.CertificateIssueDate], + value: certificates.validFrom + ? moment(certificates.validFrom).format('L LT') + : undefined, + }, + { + name: FriendlyFlyoutLabels[Metadata.CertificateExpiryDate], + value: certificates.validTo ? moment(certificates.validTo).format('L LT') : undefined, + }, + { + name: FriendlyFlyoutLabels[Metadata.CertificateSubject], + value: certificates.subjectName, + }, + ] + : undefined, + details: [ + { name: FriendlyFlyoutLabels[Metadata.MimeType], value: mimeType }, + { + name: FriendlyFlyoutLabels[Metadata.RequestStart], + value: getFriendlyMetadataValue({ value: requestStart, postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Dns], + value: getFriendlyMetadataValue({ value: dns, postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Connect], + value: getFriendlyMetadataValue({ value: getConnectingTime(connect, ssl), postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Ssl], + value: getFriendlyMetadataValue({ value: ssl, postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Wait], + value: getFriendlyMetadataValue({ value: wait, postFix: 'ms' }), + }, + { + name: FriendlyTimingLabels[Timings.Receive], + value: getFriendlyMetadataValue({ + value: contentDownloaded, + postFix: 'ms', + }), + }, + { + name: FriendlyFlyoutLabels[Metadata.BytesDownloadedCompressed], + value: getFriendlyMetadataValue({ + value: bytesDownloadedCompressed ? bytesDownloadedCompressed / 1000 : undefined, + postFix: 'KB', + }), + }, + { + name: FriendlyFlyoutLabels[Metadata.IP], + value: ip, + }, + ], + }; }; export const getSidebarItems = ( @@ -206,7 +330,7 @@ export const getSidebarItems = ( const isHighlighted = isHighlightedItem(item, query, activeFilters); const offsetIndex = index + 1; const { url, status, method } = item; - return { url, status, method, isHighlighted, offsetIndex }; + return { url, status, method, isHighlighted, offsetIndex, index }; }); if (onlyHighlighted) { return sideBarItems.filter((item) => item.isHighlighted); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts index e22caae0d9eb2a..cedf9c667d0f28 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/types.ts @@ -18,6 +18,17 @@ export enum Timings { Receive = 'receive', } +export enum Metadata { + BytesDownloadedCompressed = 'bytesDownloadedCompressed', + CertificateIssuer = 'certificateIssuer', + CertificateIssueDate = 'certificateIssueDate', + CertificateExpiryDate = 'certificateExpiryDate', + CertificateSubject = 'certificateSubject', + IP = 'ip', + MimeType = 'mimeType', + RequestStart = 'requestStart', +} + export const FriendlyTimingLabels = { [Timings.Blocked]: i18n.translate( 'xpack.uptime.synthetics.waterfallChart.labels.timings.blocked', @@ -51,6 +62,54 @@ export const FriendlyTimingLabels = { ), }; +export const FriendlyFlyoutLabels = { + [Metadata.MimeType]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.contentType', + { + defaultMessage: 'Content type', + } + ), + [Metadata.RequestStart]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.requestStart', + { + defaultMessage: 'Request start', + } + ), + [Metadata.BytesDownloadedCompressed]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.bytesDownloadedCompressed', + { + defaultMessage: 'Bytes downloaded (compressed)', + } + ), + [Metadata.CertificateIssuer]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateIssuer', + { + defaultMessage: 'Issuer', + } + ), + [Metadata.CertificateIssueDate]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateIssueDate', + { + defaultMessage: 'Valid from', + } + ), + [Metadata.CertificateExpiryDate]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateExpiryDate', + { + defaultMessage: 'Valid until', + } + ), + [Metadata.CertificateSubject]: i18n.translate( + 'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateSubject', + { + defaultMessage: 'Common name', + } + ), + [Metadata.IP]: i18n.translate('xpack.uptime.synthetics.waterfallChart.labels.metadata.ip', { + defaultMessage: 'IP', + }), +}; + export const TIMING_ORDER = [ Timings.Blocked, Timings.Dns, @@ -61,6 +120,19 @@ export const TIMING_ORDER = [ Timings.Receive, ] as const; +export const META_DATA_ORDER_FLYOUT = [ + Metadata.MimeType, + Timings.Dns, + Timings.Connect, + Timings.Ssl, + Timings.Wait, + Timings.Receive, +] as const; + +export type CalculatedTimings = { + [K in Timings]?: number; +}; + export enum MimeType { Html = 'html', Script = 'script', @@ -155,6 +227,7 @@ export type NetworkItems = NetworkItem[]; export type SidebarItem = Pick & { isHighlighted: boolean; + index: number; offsetIndex: number; }; export type SidebarItems = SidebarItem[]; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx index e22f4a4c63f596..47c18225f38d37 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.test.tsx @@ -6,14 +6,12 @@ */ import React from 'react'; -import { act, fireEvent } from '@testing-library/react'; -import { WaterfallChartWrapper } from './waterfall_chart_wrapper'; - +import { act, fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../../../../lib/helper/rtl_helpers'; +import { WaterfallChartWrapper } from './waterfall_chart_wrapper'; +import { networkItems as mockNetworkItems } from './data_formatting.test'; import { extractItems, isHighlightedItem } from './data_formatting'; - -import 'jest-canvas-mock'; import { BAR_HEIGHT } from '../../waterfall/components/constants'; import { MimeType } from './types'; import { @@ -26,8 +24,10 @@ const getHighLightedItems = (query: string, filters: string[]) => { return NETWORK_EVENTS.events.filter((item) => isHighlightedItem(item, query, filters)); }; -describe('waterfall chart wrapper', () => { - jest.useFakeTimers(); +describe('WaterfallChartWrapper', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); it('renders the correct sidebar items', () => { const { getAllByTestId } = render( @@ -129,6 +129,69 @@ describe('waterfall chart wrapper', () => { expect(queryAllByTestId('sideBarHighlightedItem')).toHaveLength(0); expect(queryAllByTestId('sideBarDimmedItem')).toHaveLength(0); }); + + it('opens flyout on sidebar click and closes on flyout close button', async () => { + const { getByText, getAllByText, getByTestId, queryByText, getByRole } = render( + + ); + + expect(getByText(`1. ${mockNetworkItems[0].url}`)).toBeInTheDocument(); + expect(queryByText('Content type')).not.toBeInTheDocument(); + expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument(); + + // open flyout + // selecter matches both button and accessible text. Button is the second element in the array; + const sidebarButton = getAllByText(/1./)[1]; + fireEvent.click(sidebarButton); + + // check for sample flyout items + await waitFor(() => { + const waterfallFlyout = getByRole('dialog'); + expect(waterfallFlyout).toBeInTheDocument(); + expect(getByText('Content type')).toBeInTheDocument(); + expect(getByText(`${mockNetworkItems[0]?.mimeType}`)).toBeInTheDocument(); + // close flyout + const closeButton = getByTestId('euiFlyoutCloseButton'); + fireEvent.click(closeButton); + }); + + /* check that sample flyout items are gone from the DOM */ + await waitFor(() => { + expect(queryByText('Content type')).not.toBeInTheDocument(); + expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument(); + }); + }); + + it('opens flyout on sidebar click and closes on second sidebar click', async () => { + const { getByText, getAllByText, getByTestId, queryByText } = render( + + ); + + expect(getByText(`1. ${mockNetworkItems[0].url}`)).toBeInTheDocument(); + expect(queryByText('Content type')).not.toBeInTheDocument(); + expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument(); + + // open flyout + // selecter matches both button and accessible text. Button is the second element in the array; + const sidebarButton = getAllByText(/1./)[1]; + fireEvent.click(sidebarButton); + + // check for sample flyout items and that the flyout is focused + await waitFor(() => { + const waterfallFlyout = getByTestId('waterfallFlyout'); + expect(waterfallFlyout).toBeInTheDocument(); + expect(getByText('Content type')).toBeInTheDocument(); + expect(getByText(`${mockNetworkItems[0]?.mimeType}`)).toBeInTheDocument(); + }); + + fireEvent.click(sidebarButton); + + /* check that sample flyout items are gone from the DOM */ + await waitFor(() => { + expect(queryByText('Content type')).not.toBeInTheDocument(); + expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument(); + }); + }); }); const NETWORK_EVENTS = { diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx index 8a0e9729a635b0..8557837abbe466 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_wrapper.tsx @@ -7,11 +7,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiHealth } from '@elastic/eui'; -import { useTrackMetric, METRIC_TYPE } from '../../../../../../../observability/public'; import { getSeriesAndDomain, getSidebarItems, getLegendItems } from './data_formatting'; import { SidebarItem, LegendItem, NetworkItems } from './types'; -import { WaterfallProvider, WaterfallChart, RenderItem } from '../../waterfall'; +import { WaterfallProvider, WaterfallChart, RenderItem, useFlyout } from '../../waterfall'; +import { useTrackMetric, METRIC_TYPE } from '../../../../../../../observability/public'; import { WaterfallFilter } from './waterfall_filter'; +import { WaterfallFlyout } from './waterfall_flyout'; import { WaterfallSidebarItem } from './waterfall_sidebar_item'; export const renderLegendItem: RenderItem = (item) => { @@ -32,7 +33,7 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { const hasFilters = activeFilters.length > 0; - const { series, domain, totalHighlightedRequests } = useMemo(() => { + const { series, domain, metadata, totalHighlightedRequests } = useMemo(() => { return getSeriesAndDomain(networkData, onlyHighlighted, query, activeFilters); }, [networkData, query, activeFilters, onlyHighlighted]); @@ -40,7 +41,18 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { return getSidebarItems(networkData, onlyHighlighted, query, activeFilters); }, [networkData, query, activeFilters, onlyHighlighted]); - const legendItems = getLegendItems(); + const legendItems = useMemo(() => { + return getLegendItems(); + }, []); + + const { + flyoutData, + onBarClick, + onProjectionClick, + onSidebarClick, + isFlyoutVisible, + onFlyoutClose, + } = useFlyout(metadata); const renderFilter = useCallback(() => { return ( @@ -55,16 +67,27 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { ); }, [activeFilters, setActiveFilters, onlyHighlighted, setOnlyHighlighted, query, setQuery]); + const renderFlyout = useCallback(() => { + return ( + + ); + }, [flyoutData, isFlyoutVisible, onFlyoutClose]); + const renderSidebarItem: RenderItem = useCallback( (item) => { return ( ); }, - [hasFilters, onlyHighlighted] + [hasFilters, onlyHighlighted, onSidebarClick] ); useTrackMetric({ app: 'uptime', metric: 'waterfall_chart_view', metricType: METRIC_TYPE.COUNT }); @@ -81,17 +104,21 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { fetchedNetworkRequests={networkData.length} highlightedNetworkRequests={totalHighlightedRequests} data={series} + onElementClick={useCallback(onBarClick, [onBarClick])} + onProjectionClick={useCallback(onProjectionClick, [onProjectionClick])} + onSidebarClick={onSidebarClick} showOnlyHighlightedNetworkRequests={onlyHighlighted} sidebarItems={sidebarItems} legendItems={legendItems} - renderTooltipItem={(tooltipProps) => { + metadata={metadata} + renderTooltipItem={useCallback((tooltipProps) => { return {tooltipProps?.value}; - }} + }, [])} > `${Number(d).toFixed(0)} ms`} + tickFormat={useCallback((d: number) => `${Number(d).toFixed(0)} ms`, [])} domain={domain} - barStyleAccessor={(datum) => { + barStyleAccessor={useCallback((datum) => { if (!datum.datum.config.isHighlighted) { return { rect: { @@ -101,9 +128,10 @@ export const WaterfallChartWrapper: React.FC = ({ data, total }) => { }; } return datum.datum.config.colour; - }} + }, [])} renderSidebarItem={renderSidebarItem} renderLegendItem={renderLegendItem} + renderFlyout={renderFlyout} renderFilter={renderFilter} fullHeight={true} /> diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.test.tsx new file mode 100644 index 00000000000000..4028bc0821b290 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.test.tsx @@ -0,0 +1,139 @@ +/* + * 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 { render } from '../../../../../lib/helper/rtl_helpers'; +import { + WaterfallFlyout, + DETAILS, + CERTIFICATES, + REQUEST_HEADERS, + RESPONSE_HEADERS, +} from './waterfall_flyout'; +import { WaterfallMetadataEntry } from '../../waterfall/types'; + +describe('WaterfallFlyout', () => { + const flyoutData: WaterfallMetadataEntry = { + x: 0, + url: 'http://elastic.co', + requestHeaders: undefined, + responseHeaders: undefined, + certificates: undefined, + details: [ + { + name: 'Content type', + value: 'text/html', + }, + ], + }; + + const defaultProps = { + flyoutData, + isFlyoutVisible: true, + onFlyoutClose: () => null, + }; + + it('displays flyout information and omits sections that are undefined', () => { + const { getByText, queryByText } = render(); + + expect(getByText(flyoutData.url)).toBeInTheDocument(); + expect(queryByText(DETAILS)).toBeInTheDocument(); + flyoutData.details.forEach((detail) => { + expect(getByText(detail.name)).toBeInTheDocument(); + expect(getByText(`${detail.value}`)).toBeInTheDocument(); + }); + + expect(queryByText(CERTIFICATES)).not.toBeInTheDocument(); + expect(queryByText(REQUEST_HEADERS)).not.toBeInTheDocument(); + expect(queryByText(RESPONSE_HEADERS)).not.toBeInTheDocument(); + }); + + it('displays flyout certificates information', () => { + const certificates = [ + { + name: 'Issuer', + value: 'Sample Issuer', + }, + { + name: 'Valid From', + value: 'January 1, 2020 7:00PM', + }, + { + name: 'Valid Until', + value: 'January 31, 2020 7:00PM', + }, + { + name: 'Common Name', + value: '*.elastic.co', + }, + ]; + const flyoutDataWithCertificates = { + ...flyoutData, + certificates, + }; + + const { getByText } = render( + + ); + + expect(getByText(flyoutData.url)).toBeInTheDocument(); + expect(getByText(DETAILS)).toBeInTheDocument(); + expect(getByText(CERTIFICATES)).toBeInTheDocument(); + flyoutData.certificates?.forEach((detail) => { + expect(getByText(detail.name)).toBeInTheDocument(); + expect(getByText(`${detail.value}`)).toBeInTheDocument(); + }); + }); + + it('displays flyout request and response headers information', () => { + const requestHeaders = [ + { + name: 'sample_request_header', + value: 'Sample Request Header value', + }, + ]; + const responseHeaders = [ + { + name: 'sample_response_header', + value: 'sample response header value', + }, + ]; + const flyoutDataWithHeaders = { + ...flyoutData, + requestHeaders, + responseHeaders, + }; + const { getByText } = render( + + ); + + expect(getByText(flyoutData.url)).toBeInTheDocument(); + expect(getByText(DETAILS)).toBeInTheDocument(); + expect(getByText(REQUEST_HEADERS)).toBeInTheDocument(); + expect(getByText(RESPONSE_HEADERS)).toBeInTheDocument(); + flyoutData.requestHeaders?.forEach((detail) => { + expect(getByText(detail.name)).toBeInTheDocument(); + expect(getByText(`${detail.value}`)).toBeInTheDocument(); + }); + flyoutData.responseHeaders?.forEach((detail) => { + expect(getByText(detail.name)).toBeInTheDocument(); + expect(getByText(`${detail.value}`)).toBeInTheDocument(); + }); + }); + + it('renders null when isFlyoutVisible is false', () => { + const { queryByText } = render(); + + expect(queryByText(flyoutData.url)).not.toBeInTheDocument(); + }); + + it('renders null when flyoutData is undefined', () => { + const { queryByText } = render(); + + expect(queryByText(flyoutData.url)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.tsx new file mode 100644 index 00000000000000..4f92c882340b94 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_flyout.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useRef } from 'react'; + +import styled from 'styled-components'; + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiTitle, + EuiSpacer, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Table } from '../../waterfall/components/waterfall_flyout_table'; +import { MiddleTruncatedText } from '../../waterfall'; +import { WaterfallMetadataEntry } from '../../waterfall/types'; +import { OnFlyoutClose } from '../../waterfall/components/use_flyout'; +import { METRIC_TYPE, useUiTracker } from '../../../../../../../observability/public'; + +export const DETAILS = i18n.translate('xpack.uptime.synthetics.waterfall.flyout.details', { + defaultMessage: 'Details', +}); + +export const CERTIFICATES = i18n.translate( + 'xpack.uptime.synthetics.waterfall.flyout.certificates', + { + defaultMessage: 'Certificate headers', + } +); + +export const REQUEST_HEADERS = i18n.translate( + 'xpack.uptime.synthetics.waterfall.flyout.requestHeaders', + { + defaultMessage: 'Request headers', + } +); + +export const RESPONSE_HEADERS = i18n.translate( + 'xpack.uptime.synthetics.waterfall.flyout.responseHeaders', + { + defaultMessage: 'Response headers', + } +); + +const FlyoutContainer = styled(EuiFlyout)` + z-index: ${(props) => props.theme.eui.euiZLevel5}; +`; + +export interface WaterfallFlyoutProps { + flyoutData?: WaterfallMetadataEntry; + onFlyoutClose: OnFlyoutClose; + isFlyoutVisible?: boolean; +} + +export const WaterfallFlyout = ({ + flyoutData, + isFlyoutVisible, + onFlyoutClose, +}: WaterfallFlyoutProps) => { + const flyoutRef = useRef(null); + const trackMetric = useUiTracker({ app: 'uptime' }); + + useEffect(() => { + if (isFlyoutVisible && flyoutData && flyoutRef.current) { + flyoutRef.current?.focus(); + } + }, [flyoutData, isFlyoutVisible, flyoutRef]); + + if (!flyoutData || !isFlyoutVisible) { + return null; + } + + const { url, details, certificates, requestHeaders, responseHeaders } = flyoutData; + + trackMetric({ metric: 'waterfall_flyout', metricType: METRIC_TYPE.CLICK }); + + return ( +
+ + + +

+ + + +

+
+
+ + + {!!requestHeaders && ( + <> + +
+ + )} + {!!responseHeaders && ( + <> + +
+ + )} + {!!certificates && ( + <> + +
+ + )} + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx index 25b577ef9403aa..f9d56422ba75cb 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_sidebar_item.tsx @@ -5,20 +5,35 @@ * 2.0. */ -import React from 'react'; +import React, { RefObject, useMemo, useCallback, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; import { SidebarItem } from '../waterfall/types'; import { MiddleTruncatedText } from '../../waterfall'; import { SideBarItemHighlighter } from '../../waterfall/components/styles'; import { SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL } from '../../waterfall/components/translations'; +import { OnSidebarClick } from '../../waterfall/components/use_flyout'; interface SidebarItemProps { item: SidebarItem; renderFilterScreenReaderText?: boolean; + onClick?: OnSidebarClick; } -export const WaterfallSidebarItem = ({ item, renderFilterScreenReaderText }: SidebarItemProps) => { - const { status, offsetIndex, isHighlighted } = item; +export const WaterfallSidebarItem = ({ + item, + renderFilterScreenReaderText, + onClick, +}: SidebarItemProps) => { + const [buttonRef, setButtonRef] = useState>(); + const { status, offsetIndex, index, isHighlighted, url } = item; + + const handleSidebarClick = useMemo(() => { + if (onClick) { + return () => onClick({ buttonRef, networkItemIndex: index }); + } + }, [buttonRef, index, onClick]); + + const setRef = useCallback((ref) => setButtonRef(ref), [setButtonRef]); const isErrorStatusCode = (statusCode: number) => { const is400 = statusCode >= 400 && statusCode <= 499; @@ -40,11 +55,23 @@ export const WaterfallSidebarItem = ({ item, renderFilterScreenReaderText }: Sid data-test-subj={isHighlighted ? 'sideBarHighlightedItem' : 'sideBarDimmedItem'} > {!status || !isErrorStatusCode(status) ? ( - + ) : ( - - - + + + {status} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx index 578d66a1ea3f1d..7f32cac92bd9fc 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfalll_sidebar_item.test.tsx @@ -6,20 +6,22 @@ */ import React from 'react'; -import { SidebarItem } from '../waterfall/types'; +import 'jest-canvas-mock'; +import { fireEvent } from '@testing-library/react'; +import { SidebarItem } from '../waterfall/types'; import { render } from '../../../../../lib/helper/rtl_helpers'; - -import 'jest-canvas-mock'; import { WaterfallSidebarItem } from './waterfall_sidebar_item'; import { SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL } from '../../waterfall/components/translations'; describe('waterfall filter', () => { const url = 'http://www.elastic.co'; - const offsetIndex = 1; + const index = 0; + const offsetIndex = index + 1; const item: SidebarItem = { url, isHighlighted: true, + index, offsetIndex, }; @@ -40,12 +42,14 @@ describe('waterfall filter', () => { }); it('does not render screen reader text when renderFilterScreenReaderText is false', () => { - const { queryByLabelText } = render( - + const onClick = jest.fn(); + const { getByRole } = render( + ); + const button = getByRole('button'); + fireEvent.click(button); - expect( - queryByLabelText(`${SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL} ${offsetIndex}. ${url}`) - ).not.toBeInTheDocument(); + expect(button).toBeInTheDocument(); + expect(onClick).toBeCalled(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx index d6c1d777a40a78..de352186e26fda 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.test.tsx @@ -6,7 +6,7 @@ */ import { getChunks, MiddleTruncatedText } from './middle_truncated_text'; -import { render, within } from '@testing-library/react'; +import { render, within, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; const longString = @@ -25,9 +25,10 @@ describe('getChunks', () => { }); describe('Component', () => { + const url = 'http://www.elastic.co'; it('renders truncated text and aria label', () => { const { getByText, getByLabelText } = render( - + ); expect(getByText(first)).toBeInTheDocument(); @@ -38,11 +39,39 @@ describe('Component', () => { it('renders screen reader only text', () => { const { getByTestId } = render( - + ); const { getByText } = within(getByTestId('middleTruncatedTextSROnly')); expect(getByText(longString)).toBeInTheDocument(); }); + + it('renders external link', () => { + const { getByText } = render( + + ); + const link = getByText('Open resource in new tab').closest('a'); + + expect(link).toHaveAttribute('href', url); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('renders a button when onClick function is passed', async () => { + const handleClick = jest.fn(); + const { getByTestId } = render( + + ); + const button = getByTestId('middleTruncatedTextButton'); + fireEvent.click(button); + + await waitFor(() => { + expect(handleClick).toBeCalled(); + }); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx index ec363ed2b40a4e..a0993d54bbd074 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx @@ -7,41 +7,57 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { EuiScreenReaderOnly, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiScreenReaderOnly, EuiToolTip, EuiButtonEmpty, EuiLink } from '@elastic/eui'; import { FIXED_AXIS_HEIGHT } from './constants'; interface Props { ariaLabel: string; text: string; + onClick?: (event: React.MouseEvent) => void; + setButtonRef?: (ref: HTMLButtonElement | HTMLAnchorElement | null) => void; + url: string; } -const OuterContainer = styled.div` - width: 100%; - height: 100%; +const OuterContainer = styled.span` position: relative; -`; + display: inline-flex; + align-items: center; + .euiToolTipAnchor { + min-width: 0; + } +`; // NOTE: min-width: 0 ensures flexbox and no-wrap children can co-exist const InnerContainer = styled.span` - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; overflow: hidden; display: flex; - min-width: 0; -`; // NOTE: min-width: 0 ensures flexbox and no-wrap children can co-exist + align-items: center; +`; const FirstChunk = styled.span` text-overflow: ellipsis; white-space: nowrap; overflow: hidden; line-height: ${FIXED_AXIS_HEIGHT}px; -`; + text-align: left; +`; // safari doesn't auto align text left in some cases const LastChunk = styled.span` flex-shrink: 0; line-height: ${FIXED_AXIS_HEIGHT}px; + text-align: left; +`; // safari doesn't auto align text left in some cases + +const StyledButton = styled(EuiButtonEmpty)` + &&& { + height: auto; + border: none; + + .euiButtonContent { + display: inline-block; + padding: 0; + } + } `; export const getChunks = (text: string) => { @@ -55,24 +71,49 @@ export const getChunks = (text: string) => { // Helper component for adding middle text truncation, e.g. // really-really-really-long....ompressed.js // Can be used to accomodate content in sidebar item rendering. -export const MiddleTruncatedText = ({ ariaLabel, text }: Props) => { +export const MiddleTruncatedText = ({ ariaLabel, text, onClick, setButtonRef, url }: Props) => { const chunks = useMemo(() => { return getChunks(text); }, [text]); return ( - <> - - - {text} - - - - {chunks.first} - {chunks.last} - - - - + + + {text} + + + <> + {onClick ? ( + + + {chunks.first} + {chunks.last} + + + ) : ( + + {chunks.first} + {chunks.last} + + )} + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx index 86ab4488cca938..0e57a210f032ac 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/sidebar.tsx @@ -5,15 +5,15 @@ * 2.0. */ -import React from 'react'; -import { EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; import { FIXED_AXIS_HEIGHT, SIDEBAR_GROW_SIZE } from './constants'; -import { IWaterfallContext } from '../context/waterfall_chart'; +import { IWaterfallContext, useWaterfallContext } from '../context/waterfall_chart'; import { WaterfallChartSidebarContainer, WaterfallChartSidebarContainerInnerPanel, WaterfallChartSidebarContainerFlexGroup, WaterfallChartSidebarFlexItem, + WaterfallChartSidebarWrapper, } from './styles'; import { WaterfallChartProps } from './waterfall_chart'; @@ -23,8 +23,11 @@ interface SidebarProps { } export const Sidebar: React.FC = ({ items, render }) => { + const { onSidebarClick } = useWaterfallContext(); + const handleSidebarClick = useMemo(() => onSidebarClick, [onSidebarClick]); + return ( - + = ({ items, render }) => { gutterSize="none" responsive={false} > - {items.map((item) => ( - - {render(item)} - - ))} + {items.map((item, index) => { + return ( + + {render(item, index, handleSidebarClick)} + + ); + })} - + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts index 9177902f8a613b..c0a75e0e09b223 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiPanelProps } from '@elastic/eui'; -import { rgba } from 'polished'; import { FunctionComponent } from 'react'; import { StyledComponent } from 'styled-components'; +import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiPanelProps } from '@elastic/eui'; +import { rgba } from 'polished'; +import { FIXED_AXIS_HEIGHT, SIDEBAR_GROW_SIZE } from './constants'; import { euiStyled, EuiTheme } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { FIXED_AXIS_HEIGHT } from './constants'; interface WaterfallChartOuterContainerProps { height?: string; @@ -82,6 +82,11 @@ interface WaterfallChartSidebarContainer { height: number; } +export const WaterfallChartSidebarWrapper = euiStyled(EuiFlexItem)` + max-width: ${SIDEBAR_GROW_SIZE * 10}%; + z-index: ${(props) => props.theme.eui.euiZLevel5}; +`; + export const WaterfallChartSidebarContainer = euiStyled.div` height: ${(props) => `${props.height}px`}; overflow-y: hidden; @@ -104,10 +109,10 @@ export const WaterfallChartSidebarFlexItem = euiStyled(EuiFlexItem)` min-width: 0; padding-left: ${(props) => props.theme.eui.paddingSizes.m}; padding-right: ${(props) => props.theme.eui.paddingSizes.m}; - z-index: ${(props) => props.theme.eui.euiZLevel4}; + justify-content: space-around; `; -export const SideBarItemHighlighter = euiStyled.span<{ isHighlighted: boolean }>` +export const SideBarItemHighlighter = euiStyled(EuiFlexItem)<{ isHighlighted: boolean }>` opacity: ${(props) => (props.isHighlighted ? 1 : 0.4)}; height: 100%; `; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.test.tsx new file mode 100644 index 00000000000000..5b388874d508e3 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useFlyout } from './use_flyout'; +import { IWaterfallContext } from '../context/waterfall_chart'; + +import { ProjectedValues, XYChartElementEvent } from '@elastic/charts'; + +describe('useFlyoutHook', () => { + const metadata: IWaterfallContext['metadata'] = [ + { + x: 0, + url: 'http://elastic.co', + requestHeaders: undefined, + responseHeaders: undefined, + certificates: undefined, + details: [ + { + name: 'Content type', + value: 'text/html', + }, + ], + }, + ]; + + it('sets isFlyoutVisible to true and sets flyoutData when calling onSidebarClick', () => { + const index = 0; + const { result } = renderHook((props) => useFlyout(props.metadata), { + initialProps: { metadata }, + }); + + expect(result.current.isFlyoutVisible).toBe(false); + + act(() => { + result.current.onSidebarClick({ buttonRef: { current: null }, networkItemIndex: index }); + }); + + expect(result.current.isFlyoutVisible).toBe(true); + expect(result.current.flyoutData).toEqual(metadata[index]); + }); + + it('sets isFlyoutVisible to true and sets flyoutData when calling onBarClick', () => { + const index = 0; + const elementData = [ + { + datum: { + config: { + id: index, + }, + }, + }, + {}, + ]; + + const { result } = renderHook((props) => useFlyout(props.metadata), { + initialProps: { metadata }, + }); + + expect(result.current.isFlyoutVisible).toBe(false); + + act(() => { + result.current.onBarClick([elementData as XYChartElementEvent]); + }); + + expect(result.current.isFlyoutVisible).toBe(true); + expect(result.current.flyoutData).toEqual(metadata[0]); + }); + + it('sets isFlyoutVisible to true and sets flyoutData when calling onProjectionClick', () => { + const index = 0; + const geometry = { x: index }; + + const { result } = renderHook((props) => useFlyout(props.metadata), { + initialProps: { metadata }, + }); + + expect(result.current.isFlyoutVisible).toBe(false); + + act(() => { + result.current.onProjectionClick(geometry as ProjectedValues); + }); + + expect(result.current.isFlyoutVisible).toBe(true); + expect(result.current.flyoutData).toEqual(metadata[0]); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.ts new file mode 100644 index 00000000000000..206fc588c3053b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_flyout.ts @@ -0,0 +1,93 @@ +/* + * 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 { RefObject, useCallback, useState } from 'react'; + +import { + ElementClickListener, + ProjectionClickListener, + ProjectedValues, + XYChartElementEvent, +} from '@elastic/charts'; + +import { WaterfallMetadata, WaterfallMetadataEntry } from '../types'; + +interface OnSidebarClickParams { + buttonRef?: ButtonRef; + networkItemIndex: number; +} + +export type ButtonRef = RefObject; +export type OnSidebarClick = (params: OnSidebarClickParams) => void; +export type OnProjectionClick = ProjectionClickListener; +export type OnElementClick = ElementClickListener; +export type OnFlyoutClose = () => void; + +export const useFlyout = (metadata: WaterfallMetadata) => { + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [flyoutData, setFlyoutData] = useState(undefined); + const [currentSidebarItemRef, setCurrentSidebarItemRef] = useState< + RefObject + >(); + + const handleFlyout = useCallback( + (flyoutEntry: WaterfallMetadataEntry) => { + setFlyoutData(flyoutEntry); + setIsFlyoutVisible(true); + }, + [setIsFlyoutVisible, setFlyoutData] + ); + + const onFlyoutClose = useCallback(() => { + setIsFlyoutVisible(false); + currentSidebarItemRef?.current?.focus(); + }, [currentSidebarItemRef, setIsFlyoutVisible]); + + const onBarClick: ElementClickListener = useCallback( + ([elementData]) => { + setIsFlyoutVisible(false); + const { datum } = (elementData as XYChartElementEvent)[0]; + const metadataEntry = metadata[datum.config.id]; + handleFlyout(metadataEntry); + }, + [metadata, handleFlyout] + ); + + const onProjectionClick: ProjectionClickListener = useCallback( + (projectionData) => { + setIsFlyoutVisible(false); + const { x } = projectionData as ProjectedValues; + if (typeof x === 'number' && x >= 0) { + const metadataEntry = metadata[x]; + handleFlyout(metadataEntry); + } + }, + [metadata, handleFlyout] + ); + + const onSidebarClick: OnSidebarClick = useCallback( + ({ buttonRef, networkItemIndex }) => { + if (isFlyoutVisible && buttonRef === currentSidebarItemRef) { + setIsFlyoutVisible(false); + } else { + const metadataEntry = metadata[networkItemIndex]; + setCurrentSidebarItemRef(buttonRef); + handleFlyout(metadataEntry); + } + }, + [currentSidebarItemRef, handleFlyout, isFlyoutVisible, metadata, setIsFlyoutVisible] + ); + + return { + flyoutData, + onBarClick, + onProjectionClick, + onSidebarClick, + isFlyoutVisible, + onFlyoutClose, + }; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx index df00df147fc6c5..19a828aa097b6f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import { Axis, BarSeries, @@ -67,6 +67,10 @@ export const WaterfallBarChart = ({ index, }: Props) => { const theme = useChartTheme(); + const { onElementClick, onProjectionClick } = useWaterfallContext(); + const handleElementClick = useMemo(() => onElementClick, [onElementClick]); + const handleProjectionClick = useMemo(() => onProjectionClick, [onProjectionClick]); + const memoizedTickFormat = useCallback(tickFormat, [tickFormat]); return ( = (item: I, index?: number) => JSX.Element; -export type RenderFilter = () => JSX.Element; +export type RenderItem = ( + item: I, + index: number, + onClick?: (event: any) => void +) => JSX.Element; +export type RenderElement = () => JSX.Element; export interface WaterfallChartProps { tickFormat: TickFormatter; @@ -36,7 +40,8 @@ export interface WaterfallChartProps { barStyleAccessor: BarStyleAccessor; renderSidebarItem?: RenderItem; renderLegendItem?: RenderItem; - renderFilter?: RenderFilter; + renderFilter?: RenderElement; + renderFlyout?: RenderElement; maxHeight?: string; fullHeight?: boolean; } @@ -48,6 +53,7 @@ export const WaterfallChart = ({ renderSidebarItem, renderLegendItem, renderFilter, + renderFlyout, maxHeight = '800px', fullHeight = false, }: WaterfallChartProps) => { @@ -82,7 +88,7 @@ export const WaterfallChart = ({ {shouldRenderSidebar && ( - + {renderFilter()} )} - + )} {shouldRenderLegend && } + {renderFlyout && renderFlyout()} ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_flyout_table.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_flyout_table.tsx new file mode 100644 index 00000000000000..8f723eb92fd947 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_flyout_table.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 styled from 'styled-components'; + +import { EuiText, EuiBasicTable, EuiSpacer } from '@elastic/eui'; + +interface Row { + name: string; + value?: string; +} + +interface Props { + rows: Row[]; + title: string; +} + +const StyledText = styled(EuiText)` + width: 100%; +`; + +class TableWithoutHeader extends EuiBasicTable { + renderTableHead() { + return <>; + } +} + +export const Table = (props: Props) => { + const { rows, title } = props; + const columns = useMemo( + () => [ + { + field: 'name', + name: '', + sortable: false, + render: (_name: string, item: Row) => ( + + {item.name} + + ), + }, + { + field: 'value', + name: '', + sortable: false, + render: (_name: string, item: Row) => { + return ( + + {item.value ?? '--'} + + ); + }, + }, + ], + [] + ); + + return ( + <> + +

{title}

+
+ + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx index 9e87d69ce38a82..b960491162010c 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/context/waterfall_chart.tsx @@ -6,7 +6,8 @@ */ import React, { createContext, useContext, Context } from 'react'; -import { WaterfallData, WaterfallDataEntry } from '../types'; +import { WaterfallData, WaterfallDataEntry, WaterfallMetadata } from '../types'; +import { OnSidebarClick, OnElementClick, OnProjectionClick } from '../components/use_flyout'; import { SidebarItems } from '../../step_detail/waterfall/types'; export interface IWaterfallContext { @@ -14,9 +15,13 @@ export interface IWaterfallContext { highlightedNetworkRequests: number; fetchedNetworkRequests: number; data: WaterfallData; + onElementClick?: OnElementClick; + onProjectionClick?: OnProjectionClick; + onSidebarClick?: OnSidebarClick; showOnlyHighlightedNetworkRequests: boolean; sidebarItems?: SidebarItems; legendItems?: unknown[]; + metadata: WaterfallMetadata; renderTooltipItem: ( item: WaterfallDataEntry['config']['tooltipProps'], index?: number @@ -30,18 +35,26 @@ interface ProviderProps { highlightedNetworkRequests: number; fetchedNetworkRequests: number; data: IWaterfallContext['data']; + onElementClick?: IWaterfallContext['onElementClick']; + onProjectionClick?: IWaterfallContext['onProjectionClick']; + onSidebarClick?: IWaterfallContext['onSidebarClick']; showOnlyHighlightedNetworkRequests: IWaterfallContext['showOnlyHighlightedNetworkRequests']; sidebarItems?: IWaterfallContext['sidebarItems']; legendItems?: IWaterfallContext['legendItems']; + metadata: IWaterfallContext['metadata']; renderTooltipItem: IWaterfallContext['renderTooltipItem']; } export const WaterfallProvider: React.FC = ({ children, data, + onElementClick, + onProjectionClick, + onSidebarClick, showOnlyHighlightedNetworkRequests, sidebarItems, legendItems, + metadata, renderTooltipItem, totalNetworkRequests, highlightedNetworkRequests, @@ -54,6 +67,10 @@ export const WaterfallProvider: React.FC = ({ showOnlyHighlightedNetworkRequests, sidebarItems, legendItems, + metadata, + onElementClick, + onProjectionClick, + onSidebarClick, renderTooltipItem, totalNetworkRequests, highlightedNetworkRequests, diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/index.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/index.tsx index 5a6daa30450d13..0de1b50ecce8f7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/index.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/index.tsx @@ -8,4 +8,10 @@ export { WaterfallChart, RenderItem, WaterfallChartProps } from './components/waterfall_chart'; export { WaterfallProvider, useWaterfallContext } from './context/waterfall_chart'; export { MiddleTruncatedText } from './components/middle_truncated_text'; -export { WaterfallData, WaterfallDataEntry } from './types'; +export { useFlyout } from './components/use_flyout'; +export { + WaterfallData, + WaterfallDataEntry, + WaterfallMetadata, + WaterfallMetadataEntry, +} from './types'; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/types.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/types.ts index 6cffc3a2df382d..f1775a6fd18921 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/types.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/types.ts @@ -16,8 +16,26 @@ export interface WaterfallDataSeriesConfigProperties { showTooltip: boolean; } +export interface WaterfallMetadataItem { + name: string; + value?: string; +} + +export interface WaterfallMetadataEntry { + x: number; + url: string; + requestHeaders?: WaterfallMetadataItem[]; + responseHeaders?: WaterfallMetadataItem[]; + certificates?: WaterfallMetadataItem[]; + details: WaterfallMetadataItem[]; +} + export type WaterfallDataEntry = PlotProperties & { config: WaterfallDataSeriesConfigProperties & Record; }; +export type WaterfallMetadata = WaterfallMetadataEntry[]; + export type WaterfallData = WaterfallDataEntry[]; + +export type RenderItem = (item: I, index: number) => JSX.Element; diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.ts index 3fce0499c45015..17b3354b666c48 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.ts @@ -119,6 +119,8 @@ function getServiceNowActionParams(): ServiceNowActionParams { impact: '2', severity: '2', urgency: '2', + category: null, + subcategory: null, externalId: null, }, comments: [], diff --git a/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts index f5b6d21d40d8f2..9d4e42337fd756 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_network_events.test.ts @@ -239,11 +239,43 @@ describe('getNetworkEvents', () => { Object { "events": Array [ Object { + "bytesDownloadedCompressed": 337, + "certificates": Object { + "issuer": "DigiCert TLS RSA SHA256 2020 CA1", + "subjectName": "syndication.twitter.com", + "validFrom": 1606694400000, + "validTo": 1638230399000, + }, + "ip": "104.244.42.200", "loadEndTime": 3287298.251, "method": "GET", "mimeType": "image/gif", + "requestHeaders": Object { + "referer": "www.test.com", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/88.0.4324.0 Safari/537.36", + }, "requestSentTime": 3287154.973, "requestStartTime": 3287155.502, + "responseHeaders": Object { + "cache_control": "no-cache, no-store, must-revalidate, pre-check=0, post-check=0", + "content_encoding": "gzip", + "content_length": "65", + "content_type": "image/gif;charset=utf-8", + "date": "Mon, 14 Dec 2020 10:46:39 GMT", + "expires": "Tue, 31 Mar 1981 05:00:00 GMT", + "last_modified": "Mon, 14 Dec 2020 10:46:39 GMT", + "pragma": "no-cache", + "server": "tsa_f", + "status": "200 OK", + "strict_transport_security": "max-age=631138519", + "x_connection_hash": "cb6fe99b8676f4e4b827cc3e6512c90d", + "x_content_type_options": "nosniff", + "x_frame_options": "SAMEORIGIN", + "x_response_time": "108", + "x_transaction": "008fff3d00a1e64c", + "x_twitter_response_tags": "BouncerCompliant", + "x_xss_protection": "0", + }, "status": 200, "timestamp": "2020-12-14T10:46:39.183Z", "timings": Object { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts b/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts index fa76da00253051..970af80576cad8 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_network_events.ts @@ -50,6 +50,7 @@ export const getNetworkEvents: UMElasticsearchQueryFn< event._source.synthetics.payload.response.timing ? secondsToMillis(event._source.synthetics.payload.response.timing.request_time) : undefined; + const securityDetails = event._source.synthetics.payload.response?.security_details; return { timestamp: event._source['@timestamp'], @@ -61,6 +62,22 @@ export const getNetworkEvents: UMElasticsearchQueryFn< requestStartTime, loadEndTime, timings: event._source.synthetics.payload.timings, + bytesDownloadedCompressed: event._source.synthetics.payload.response?.encoded_data_length, + certificates: securityDetails + ? { + issuer: securityDetails.issuer, + subjectName: securityDetails.subject_name, + validFrom: securityDetails.valid_from + ? secondsToMillis(securityDetails.valid_from) + : undefined, + validTo: securityDetails.valid_to + ? secondsToMillis(securityDetails.valid_to) + : undefined, + } + : undefined, + requestHeaders: event._source.synthetics.payload.request?.headers, + responseHeaders: event._source.synthetics.payload.response?.headers, + ip: event._source.synthetics.payload.response?.remote_i_p_address, }; }), }; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json index 9a7bedbb5c6d5c..6a43c7c74ad8c2 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/aad/kibana.json @@ -1,5 +1,5 @@ { - "id": "aad-fixtures", + "id": "aadFixtures", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json index 5f92b9e5479e84..f63d6ef0d45acd 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/kibana.json @@ -1,5 +1,5 @@ { - "id": "actions_simulators", + "id": "actionsSimulators", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts index e2cbd3628d5fa1..8b8eb469897873 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts @@ -30,6 +30,8 @@ export function initPlugin(router: IRouter, path: string) { severity: schema.string({ defaultValue: '1' }), urgency: schema.string({ defaultValue: '1' }), impact: schema.string({ defaultValue: '1' }), + category: schema.maybe(schema.string()), + subcategory: schema.maybe(schema.string()), }), }, }, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json index 8f606276998f58..2f8117163471d4 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager_fixture/kibana.json @@ -1,5 +1,5 @@ { - "id": "task_manager_fixture", + "id": "taskManagerFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 2d49c409a18fc5..2d584f764e5e47 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -40,6 +40,8 @@ export default function servicenowTest({ getService }: FtrProviderContext) { severity: '1', short_description: 'a title', urgency: '1', + category: 'software', + subcategory: 'software', }, comments: [ { diff --git a/x-pack/test/api_integration/apis/metrics_ui/index.js b/x-pack/test/api_integration/apis/metrics_ui/index.js index 34ad92e6b89a61..861d82733a0fad 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/index.js +++ b/x-pack/test/api_integration/apis/metrics_ui/index.js @@ -8,9 +8,7 @@ export default function ({ loadTestFile }) { describe('MetricsUI Endpoints', () => { loadTestFile(require.resolve('./metadata')); - loadTestFile(require.resolve('./log_entries')); loadTestFile(require.resolve('./log_entry_highlights')); - loadTestFile(require.resolve('./logs_without_millis')); loadTestFile(require.resolve('./log_sources')); loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./metrics')); diff --git a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts b/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts deleted file mode 100644 index 7299c3ff31b22b..00000000000000 --- a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts +++ /dev/null @@ -1,410 +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 expect from '@kbn/expect'; -import { v4 as uuidv4 } from 'uuid'; -import { - LOG_ENTRIES_PATH, - logEntriesRequestRT, - logEntriesResponseRT, -} from '../../../../plugins/infra/common/http_api'; -import { - LogFieldColumn, - LogMessageColumn, - LogTimestampColumn, -} from '../../../../plugins/infra/common/log_entry'; -import { decodeOrThrow } from '../../../../plugins/infra/common/runtime_types'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -const KEY_WITHIN_DATA_RANGE = { - time: new Date('2018-10-17T19:50:00.000Z').valueOf(), - tiebreaker: 0, -}; -const EARLIEST_KEY_WITH_DATA = { - time: new Date('2018-10-17T19:42:22.000Z').valueOf(), - tiebreaker: 5497614, -}; -const LATEST_KEY_WITH_DATA = { - time: new Date('2018-10-17T19:57:21.611Z').valueOf(), - tiebreaker: 5603910, -}; - -const COMMON_HEADERS = { - 'kbn-xsrf': 'some-xsrf-token', -}; - -export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const supertest = getService('supertest'); - const sourceConfigurationService = getService('infraOpsSourceConfiguration'); - - describe('log entry apis', () => { - before(() => esArchiver.load('infra/metrics_and_logs')); - after(() => esArchiver.unload('infra/metrics_and_logs')); - - describe('/log_entries/entries', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('works', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: KEY_WITHIN_DATA_RANGE.time, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const firstEntry = entries[0]; - const lastEntry = entries[entries.length - 1]; - - // Has the default page size - expect(entries).to.have.length(200); - - // Cursors are set correctly - expect(firstEntry.cursor).to.eql(logEntriesResponse.data.topCursor); - expect(lastEntry.cursor).to.eql(logEntriesResponse.data.bottomCursor); - - // Entries fall within range - // @kbn/expect doesn't have a `lessOrEqualThan` or `moreOrEqualThan` comparators - expect(firstEntry.cursor.time >= EARLIEST_KEY_WITH_DATA.time).to.be(true); - expect(lastEntry.cursor.time <= KEY_WITHIN_DATA_RANGE.time).to.be(true); - }); - - it('Returns the default columns', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const entry = entries[0]; - expect(entry.columns).to.have.length(3); - - const timestampColumn = entry.columns[0] as LogTimestampColumn; - expect(timestampColumn).to.have.property('timestamp'); - - const eventDatasetColumn = entry.columns[1] as LogFieldColumn; - expect(eventDatasetColumn).to.have.property('field'); - expect(eventDatasetColumn.field).to.be('event.dataset'); - expect(eventDatasetColumn).to.have.property('value'); - - const messageColumn = entry.columns[2] as LogMessageColumn; - expect(messageColumn).to.have.property('message'); - expect(messageColumn.message.length).to.be.greaterThan(0); - }); - - it('Returns custom column configurations', async () => { - const customColumns = [ - { timestampColumn: { id: uuidv4() } }, - { fieldColumn: { id: uuidv4(), field: 'host.name' } }, - { fieldColumn: { id: uuidv4(), field: 'event.dataset' } }, - { messageColumn: { id: uuidv4() } }, - ]; - - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - columns: customColumns, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const entry = entries[0]; - expect(entry.columns).to.have.length(4); - - const timestampColumn = entry.columns[0] as LogTimestampColumn; - expect(timestampColumn).to.have.property('timestamp'); - - const hostNameColumn = entry.columns[1] as LogFieldColumn; - expect(hostNameColumn).to.have.property('field'); - expect(hostNameColumn.field).to.be('host.name'); - expect(hostNameColumn).to.have.property('value'); - - const eventDatasetColumn = entry.columns[2] as LogFieldColumn; - expect(eventDatasetColumn).to.have.property('field'); - expect(eventDatasetColumn.field).to.be('event.dataset'); - expect(eventDatasetColumn).to.have.property('value'); - - const messageColumn = entry.columns[3] as LogMessageColumn; - expect(messageColumn).to.have.property('message'); - expect(messageColumn.message.length).to.be.greaterThan(0); - }); - - it('Does not build context if entry does not have all fields', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const entry = entries[0]; - expect(entry.context).to.eql({}); - }); - - it('Paginates correctly with `after`', async () => { - const { body: firstPageBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: KEY_WITHIN_DATA_RANGE.time, - size: 10, - }) - ); - const firstPage = decodeOrThrow(logEntriesResponseRT)(firstPageBody); - - const { body: secondPageBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: KEY_WITHIN_DATA_RANGE.time, - after: firstPage.data.bottomCursor!, - size: 10, - }) - ); - const secondPage = decodeOrThrow(logEntriesResponseRT)(secondPageBody); - - const { body: bothPagesBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: KEY_WITHIN_DATA_RANGE.time, - size: 20, - }) - ); - const bothPages = decodeOrThrow(logEntriesResponseRT)(bothPagesBody); - - expect(bothPages.data.entries).to.eql([ - ...firstPage.data.entries, - ...secondPage.data.entries, - ]); - - expect(bothPages.data.topCursor).to.eql(firstPage.data.topCursor); - expect(bothPages.data.bottomCursor).to.eql(secondPage.data.bottomCursor); - }); - - it('Paginates correctly with `before`', async () => { - const { body: lastPageBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: KEY_WITHIN_DATA_RANGE.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - before: 'last', - size: 10, - }) - ); - const lastPage = decodeOrThrow(logEntriesResponseRT)(lastPageBody); - - const { body: secondToLastPageBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: KEY_WITHIN_DATA_RANGE.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - before: lastPage.data.topCursor!, - size: 10, - }) - ); - const secondToLastPage = decodeOrThrow(logEntriesResponseRT)(secondToLastPageBody); - - const { body: bothPagesBody } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: KEY_WITHIN_DATA_RANGE.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - before: 'last', - size: 20, - }) - ); - const bothPages = decodeOrThrow(logEntriesResponseRT)(bothPagesBody); - - expect(bothPages.data.entries).to.eql([ - ...secondToLastPage.data.entries, - ...lastPage.data.entries, - ]); - - expect(bothPages.data.topCursor).to.eql(secondToLastPage.data.topCursor); - expect(bothPages.data.bottomCursor).to.eql(lastPage.data.bottomCursor); - }); - - it('centers entries around a point', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const firstEntry = entries[0]; - const lastEntry = entries[entries.length - 1]; - - expect(entries).to.have.length(200); - expect(firstEntry.cursor.time >= EARLIEST_KEY_WITH_DATA.time).to.be(true); - expect(lastEntry.cursor.time <= LATEST_KEY_WITH_DATA.time).to.be(true); - }); - - it('Handles empty responses', async () => { - const startTimestamp = Date.now() + 1000; - const endTimestamp = Date.now() + 5000; - - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp, - endTimestamp, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - expect(logEntriesResponse.data.entries).to.have.length(0); - expect(logEntriesResponse.data.topCursor).to.be(null); - expect(logEntriesResponse.data.bottomCursor).to.be(null); - }); - }); - - describe('with a configured source', () => { - before(async () => { - await esArchiver.load('empty_kibana'); - await sourceConfigurationService.createConfiguration('default', { - name: 'Test Source', - logColumns: [ - { - timestampColumn: { - id: uuidv4(), - }, - }, - { - fieldColumn: { - id: uuidv4(), - field: 'host.name', - }, - }, - { - fieldColumn: { - id: uuidv4(), - field: 'event.dataset', - }, - }, - { - messageColumn: { - id: uuidv4(), - }, - }, - ], - }); - }); - after(() => esArchiver.unload('empty_kibana')); - - it('returns the configured columns', async () => { - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp: EARLIEST_KEY_WITH_DATA.time, - endTimestamp: LATEST_KEY_WITH_DATA.time, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - - const logEntriesResponse = decodeOrThrow(logEntriesResponseRT)(body); - - const entries = logEntriesResponse.data.entries; - const entry = entries[0]; - - expect(entry.columns).to.have.length(4); - - const timestampColumn = entry.columns[0] as LogTimestampColumn; - expect(timestampColumn).to.have.property('timestamp'); - - const hostNameColumn = entry.columns[1] as LogFieldColumn; - expect(hostNameColumn).to.have.property('field'); - expect(hostNameColumn.field).to.be('host.name'); - expect(hostNameColumn).to.have.property('value'); - - const eventDatasetColumn = entry.columns[2] as LogFieldColumn; - expect(eventDatasetColumn).to.have.property('field'); - expect(eventDatasetColumn.field).to.be('event.dataset'); - expect(eventDatasetColumn).to.have.property('value'); - - const messageColumn = entry.columns[3] as LogMessageColumn; - expect(messageColumn).to.have.property('message'); - expect(messageColumn.message.length).to.be.greaterThan(0); - }); - }); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/metrics_ui/logs_without_millis.ts b/x-pack/test/api_integration/apis/metrics_ui/logs_without_millis.ts deleted file mode 100644 index 864766b0e0710c..00000000000000 --- a/x-pack/test/api_integration/apis/metrics_ui/logs_without_millis.ts +++ /dev/null @@ -1,130 +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 expect from '@kbn/expect'; - -import { pipe } from 'fp-ts/lib/pipeable'; -import { identity } from 'fp-ts/lib/function'; -import { fold } from 'fp-ts/lib/Either'; - -import { createPlainError, throwErrors } from '../../../../plugins/infra/common/runtime_types'; - -import { FtrProviderContext } from '../../ftr_provider_context'; -import { - LOG_ENTRIES_SUMMARY_PATH, - logEntriesSummaryRequestRT, - logEntriesSummaryResponseRT, - LOG_ENTRIES_PATH, - logEntriesRequestRT, - logEntriesResponseRT, -} from '../../../../plugins/infra/common/http_api/log_entries'; - -const COMMON_HEADERS = { - 'kbn-xsrf': 'some-xsrf-token', -}; -const EARLIEST_KEY_WITH_DATA = { - time: new Date('2019-01-05T23:59:23.000Z').valueOf(), - tiebreaker: -1, -}; -const LATEST_KEY_WITH_DATA = { - time: new Date('2019-01-06T23:59:23.000Z').valueOf(), - tiebreaker: 2, -}; -const KEY_WITHIN_DATA_RANGE = { - time: new Date('2019-01-06T00:00:00.000Z').valueOf(), - tiebreaker: 0, -}; - -export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const supertest = getService('supertest'); - - describe('logs without epoch_millis format', () => { - before(() => esArchiver.load('infra/logs_without_epoch_millis')); - after(() => esArchiver.unload('infra/logs_without_epoch_millis')); - - describe('/log_entries/summary', () => { - it('returns non-empty buckets', async () => { - const startTimestamp = EARLIEST_KEY_WITH_DATA.time; - const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); - - const { body } = await supertest - .post(LOG_ENTRIES_SUMMARY_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesSummaryRequestRT.encode({ - sourceId: 'default', - startTimestamp, - endTimestamp, - bucketSize, - query: null, - }) - ) - .expect(200); - - const logSummaryResponse = pipe( - logEntriesSummaryResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - - expect( - logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) - ).to.have.length(2); - }); - }); - - describe('/log_entries/entries', () => { - it('returns log entries', async () => { - const startTimestamp = EARLIEST_KEY_WITH_DATA.time; - const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp, - endTimestamp, - }) - ) - .expect(200); - - const logEntriesResponse = pipe( - logEntriesResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - expect(logEntriesResponse.data.entries).to.have.length(2); - }); - - it('returns log entries when centering around a point', async () => { - const startTimestamp = EARLIEST_KEY_WITH_DATA.time; - const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - - const { body } = await supertest - .post(LOG_ENTRIES_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesRequestRT.encode({ - sourceId: 'default', - startTimestamp, - endTimestamp, - center: KEY_WITHIN_DATA_RANGE, - }) - ) - .expect(200); - - const logEntriesResponse = pipe( - logEntriesResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - expect(logEntriesResponse.data.entries).to.have.length(2); - }); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts index 8064d498774a39..c4b3a4ed0adcf8 100644 --- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts @@ -713,7 +713,8 @@ export default ({ getService }: FtrProviderContext) => { return successObjects; } - describe('module setup', function () { + // blocks ES snapshot promotion: https://github.com/elastic/kibana/issues/91224 + describe.skip('module setup', function () { before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); }); diff --git a/x-pack/test/api_integration/apis/search/session.ts b/x-pack/test/api_integration/apis/search/session.ts index 984f3e3f7dd4e6..27010a6ab90f6f 100644 --- a/x-pack/test/api_integration/apis/search/session.ts +++ b/x-pack/test/api_integration/apis/search/session.ts @@ -11,6 +11,9 @@ import { SearchSessionStatus } from '../../../../plugins/data_enhanced/common'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const security = getService('security'); + const retry = getService('retry'); describe('search session', () => { describe('session management', () => { @@ -152,20 +155,23 @@ export default function ({ getService }: FtrProviderContext) { const { id: id2 } = searchRes2.body; - const resp = await supertest - .get(`/internal/session/${sessionId}`) - .set('kbn-xsrf', 'foo') - .expect(200); - - const { name, touched, created, persisted, idMapping } = resp.body.attributes; - expect(persisted).to.be(true); - expect(name).to.be('My Session'); - expect(touched).not.to.be(undefined); - expect(created).not.to.be(undefined); - - const idMappings = Object.values(idMapping).map((value: any) => value.id); - expect(idMappings).to.contain(id1); - expect(idMappings).to.contain(id2); + await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + const resp = await supertest + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .expect(200); + + const { name, touched, created, persisted, idMapping } = resp.body.attributes; + expect(persisted).to.be(true); + expect(name).to.be('My Session'); + expect(touched).not.to.be(undefined); + expect(created).not.to.be(undefined); + + const idMappings = Object.values(idMapping).map((value: any) => value.id); + expect(idMappings).to.contain(id1); + expect(idMappings).to.contain(id2); + return true; + }); }); it('should create and extend a session', async () => { @@ -245,21 +251,24 @@ export default function ({ getService }: FtrProviderContext) { const { id: id2 } = searchRes2.body; - const resp = await supertest - .get(`/internal/session/${sessionId}`) - .set('kbn-xsrf', 'foo') - .expect(200); + await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + const resp = await supertest + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .expect(200); - const { appId, name, touched, created, persisted, idMapping } = resp.body.attributes; - expect(persisted).to.be(false); - expect(name).to.be(undefined); - expect(appId).to.be(undefined); - expect(touched).not.to.be(undefined); - expect(created).not.to.be(undefined); + const { appId, name, touched, created, persisted, idMapping } = resp.body.attributes; + expect(persisted).to.be(false); + expect(name).to.be(undefined); + expect(appId).to.be(undefined); + expect(touched).not.to.be(undefined); + expect(created).not.to.be(undefined); - const idMappings = Object.values(idMapping).map((value: any) => value.id); - expect(idMappings).to.contain(id1); - expect(idMappings).to.contain(id2); + const idMappings = Object.values(idMapping).map((value: any) => value.id); + expect(idMappings).to.contain(id1); + expect(idMappings).to.contain(id2); + return true; + }); }); it('touched time updates when you poll on an search', async () => { @@ -287,7 +296,7 @@ export default function ({ getService }: FtrProviderContext) { const { id: id1 } = searchRes1.body; // it might take the session a moment to be created - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 2500)); const getSessionFirstTime = await supertest .get(`/internal/session/${sessionId}`) @@ -303,6 +312,9 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); + // it might take the session a moment to be updated + await new Promise((resolve) => setTimeout(resolve, 2500)); + const getSessionSecondTime = await supertest .get(`/internal/session/${sessionId}`) .set('kbn-xsrf', 'foo') @@ -315,5 +327,168 @@ export default function ({ getService }: FtrProviderContext) { getSessionSecondTime.body.attributes.touched ); }); + + describe('with security', () => { + before(async () => { + await security.user.create('other_user', { + password: 'password', + roles: ['superuser'], + full_name: 'other user', + }); + }); + + after(async () => { + await security.user.delete('other_user'); + }); + + it(`should prevent users from accessing other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from deleting other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .delete(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from cancelling other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .post(`/internal/session/${sessionId}/cancel`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .expect(404); + }); + + it(`should prevent users from extending other users' sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertest + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(200); + + await supertestWithoutAuth + .post(`/internal/session/${sessionId}/_extend`) + .set('kbn-xsrf', 'foo') + .auth('other_user', 'password') + .send({ + expires: '2021-02-26T21:02:43.742Z', + }) + .expect(404); + }); + + it(`should prevent unauthorized users from creating sessions`, async () => { + const sessionId = `my-session-${Math.random()}`; + await supertestWithoutAuth + .post(`/internal/session`) + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(401); + }); + }); + + describe('search session permissions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read'], + }, + spaces: ['*'], + }, + ], + }); + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + }); + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + }); + + it('should 403 if no app gives permissions to store search sessions', async () => { + const sessionId = `my-session-${Math.random()}`; + await supertestWithoutAuth + .post(`/internal/session`) + .auth('analyst', 'analyst-password') + .set('kbn-xsrf', 'foo') + .send({ + sessionId, + name: 'My Session', + appId: 'discover', + expires: '123', + urlGeneratorId: 'discover', + }) + .expect(403); + + await supertestWithoutAuth + .get(`/internal/session/${sessionId}`) + .auth('analyst', 'analyst-password') + .set('kbn-xsrf', 'foo') + .expect(403); + }); + }); }); } diff --git a/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts b/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts index 3d76513b8379dd..f79885246b0acf 100644 --- a/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts +++ b/x-pack/test/api_integration/apis/security_solution/uncommon_processes.ts @@ -20,7 +20,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('uncommon_processes', () => { + // FLAKY: https://github.com/elastic/kibana/issues/90416 + describe.skip('uncommon_processes', () => { before(() => esArchiver.load('auditbeat/hosts')); after(() => esArchiver.unload('auditbeat/hosts')); diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index a59cfab2ba33bf..72ca22ae749ca7 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -62,7 +62,8 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./transactions/latency')); loadTestFile(require.resolve('./transactions/throughput')); loadTestFile(require.resolve('./transactions/top_transaction_groups')); - loadTestFile(require.resolve('./transactions/transactions_groups_overview')); + loadTestFile(require.resolve('./transactions/transactions_groups_primary_statistics')); + loadTestFile(require.resolve('./transactions/transactions_groups_comparison_statistics')); loadTestFile(require.resolve('./feature_controls')); diff --git a/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts b/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts index 321ef0c4a76380..fde12105518162 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/dependencies/index.ts @@ -6,20 +6,17 @@ */ import expect from '@kbn/expect'; +import { last, omit, pick, sortBy } from 'lodash'; import url from 'url'; -import { sortBy, pick, last, omit } from 'lodash'; import { ValuesType } from 'utility-types'; -import { registry } from '../../../common/registry'; -import { Maybe } from '../../../../../plugins/apm/typings/common'; -import { isFiniteNumber } from '../../../../../plugins/apm/common/utils/is_finite_number'; -import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; +import { roundNumber } from '../../../utils'; import { ENVIRONMENT_ALL } from '../../../../../plugins/apm/common/environment_filter_values'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi'; import archives from '../../../common/fixtures/es_archiver/archives_metadata'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { registry } from '../../../common/registry'; import { apmDependenciesMapping, createServiceDependencyDocs } from './es_utils'; -const round = (num: Maybe): string => (isFiniteNumber(num) ? num.toPrecision(4) : ''); - export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const es = getService('es'); @@ -235,9 +232,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(opbeansNode !== undefined).to.be(true); const values = { - latency: round(opbeansNode?.latency.value), - throughput: round(opbeansNode?.throughput.value), - errorRate: round(opbeansNode?.errorRate.value), + latency: roundNumber(opbeansNode?.latency.value), + throughput: roundNumber(opbeansNode?.throughput.value), + errorRate: roundNumber(opbeansNode?.errorRate.value), ...pick(opbeansNode, 'serviceName', 'type', 'agentName', 'environment', 'impact'), }; @@ -250,16 +247,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { environment: '', serviceName: 'opbeans-node', type: 'service', - errorRate: round(errors / count), - latency: round(sum / count), - throughput: round(count / ((endTime - startTime) / 1000 / 60)), + errorRate: roundNumber(errors / count), + latency: roundNumber(sum / count), + throughput: roundNumber(count / ((endTime - startTime) / 1000 / 60)), impact: 100, }); - const firstValue = round(opbeansNode?.latency.timeseries[0].y); - const lastValue = round(last(opbeansNode?.latency.timeseries)?.y); + const firstValue = roundNumber(opbeansNode?.latency.timeseries[0].y); + const lastValue = roundNumber(last(opbeansNode?.latency.timeseries)?.y); - expect(firstValue).to.be(round(20 / 3)); + expect(firstValue).to.be(roundNumber(20 / 3)); expect(lastValue).to.be('1.000'); }); @@ -271,9 +268,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(postgres !== undefined).to.be(true); const values = { - latency: round(postgres?.latency.value), - throughput: round(postgres?.throughput.value), - errorRate: round(postgres?.errorRate.value), + latency: roundNumber(postgres?.latency.value), + throughput: roundNumber(postgres?.throughput.value), + errorRate: roundNumber(postgres?.errorRate.value), ...pick(postgres, 'spanType', 'spanSubtype', 'name', 'impact', 'type'), }; @@ -286,9 +283,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { spanSubtype: 'http', name: 'postgres', type: 'external', - errorRate: round(errors / count), - latency: round(sum / count), - throughput: round(count / ((endTime - startTime) / 1000 / 60)), + errorRate: roundNumber(errors / count), + latency: roundNumber(sum / count), + throughput: roundNumber(count / ((endTime - startTime) / 1000 / 60)), impact: 0, }); }); diff --git a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap index eee0ec7f9ad38a..b4fd2219cb7331 100644 --- a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap +++ b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap @@ -8,7 +8,7 @@ Array [ }, Object { "x": 1607435880000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607435910000, @@ -16,7 +16,7 @@ Array [ }, Object { "x": 1607435940000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607435970000, @@ -24,11 +24,11 @@ Array [ }, Object { "x": 1607436000000, - "y": 0.1, + "y": 6, }, Object { "x": 1607436030000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436060000, @@ -40,7 +40,7 @@ Array [ }, Object { "x": 1607436120000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607436150000, @@ -56,7 +56,7 @@ Array [ }, Object { "x": 1607436240000, - "y": 0.2, + "y": 12, }, Object { "x": 1607436270000, @@ -68,15 +68,15 @@ Array [ }, Object { "x": 1607436330000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436360000, - "y": 0.166666666666667, + "y": 10, }, Object { "x": 1607436390000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436420000, @@ -88,11 +88,11 @@ Array [ }, Object { "x": 1607436480000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436510000, - "y": 0.166666666666667, + "y": 10, }, Object { "x": 1607436540000, @@ -104,11 +104,11 @@ Array [ }, Object { "x": 1607436600000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436630000, - "y": 0.233333333333333, + "y": 14, }, Object { "x": 1607436660000, @@ -124,7 +124,7 @@ Array [ }, Object { "x": 1607436750000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436780000, @@ -132,15 +132,15 @@ Array [ }, Object { "x": 1607436810000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436840000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607436870000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436900000, @@ -152,11 +152,11 @@ Array [ }, Object { "x": 1607436960000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607436990000, - "y": 0.133333333333333, + "y": 8, }, Object { "x": 1607437020000, @@ -168,11 +168,11 @@ Array [ }, Object { "x": 1607437080000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437110000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437140000, @@ -184,15 +184,15 @@ Array [ }, Object { "x": 1607437200000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607437230000, - "y": 0.233333333333333, + "y": 14, }, Object { "x": 1607437260000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437290000, @@ -200,11 +200,11 @@ Array [ }, Object { "x": 1607437320000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437350000, - "y": 0.0666666666666667, + "y": 4, }, Object { "x": 1607437380000, @@ -216,11 +216,11 @@ Array [ }, Object { "x": 1607437440000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437470000, - "y": 0.1, + "y": 6, }, Object { "x": 1607437500000, @@ -232,7 +232,7 @@ Array [ }, Object { "x": 1607437560000, - "y": 0.0333333333333333, + "y": 2, }, Object { "x": 1607437590000, @@ -248,3 +248,740 @@ Array [ }, ] `; + +exports[`APM API tests basic apm_8.0.0 Throughput when data is loaded with time comparison has the correct throughput 1`] = ` +Object { + "currentPeriod": Array [ + Object { + "x": 1607436770000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436790000, + "y": 0, + }, + Object { + "x": 1607436800000, + "y": 0, + }, + Object { + "x": 1607436810000, + "y": 0, + }, + Object { + "x": 1607436820000, + "y": 6, + }, + Object { + "x": 1607436830000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 0, + }, + Object { + "x": 1607436850000, + "y": 0, + }, + Object { + "x": 1607436860000, + "y": 6, + }, + Object { + "x": 1607436870000, + "y": 6, + }, + Object { + "x": 1607436880000, + "y": 6, + }, + Object { + "x": 1607436890000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436910000, + "y": 0, + }, + Object { + "x": 1607436920000, + "y": 0, + }, + Object { + "x": 1607436930000, + "y": 0, + }, + Object { + "x": 1607436940000, + "y": 0, + }, + Object { + "x": 1607436950000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607436970000, + "y": 0, + }, + Object { + "x": 1607436980000, + "y": 12, + }, + Object { + "x": 1607436990000, + "y": 6, + }, + Object { + "x": 1607437000000, + "y": 18, + }, + Object { + "x": 1607437010000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437030000, + "y": 0, + }, + Object { + "x": 1607437040000, + "y": 0, + }, + Object { + "x": 1607437050000, + "y": 0, + }, + Object { + "x": 1607437060000, + "y": 0, + }, + Object { + "x": 1607437070000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437090000, + "y": 0, + }, + Object { + "x": 1607437100000, + "y": 6, + }, + Object { + "x": 1607437110000, + "y": 6, + }, + Object { + "x": 1607437120000, + "y": 0, + }, + Object { + "x": 1607437130000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437150000, + "y": 0, + }, + Object { + "x": 1607437160000, + "y": 0, + }, + Object { + "x": 1607437170000, + "y": 0, + }, + Object { + "x": 1607437180000, + "y": 0, + }, + Object { + "x": 1607437190000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 0, + }, + Object { + "x": 1607437210000, + "y": 0, + }, + Object { + "x": 1607437220000, + "y": 12, + }, + Object { + "x": 1607437230000, + "y": 30, + }, + Object { + "x": 1607437240000, + "y": 12, + }, + Object { + "x": 1607437250000, + "y": 0, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437270000, + "y": 6, + }, + Object { + "x": 1607437280000, + "y": 0, + }, + Object { + "x": 1607437290000, + "y": 0, + }, + Object { + "x": 1607437300000, + "y": 0, + }, + Object { + "x": 1607437310000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437330000, + "y": 0, + }, + Object { + "x": 1607437340000, + "y": 6, + }, + Object { + "x": 1607437350000, + "y": 0, + }, + Object { + "x": 1607437360000, + "y": 12, + }, + Object { + "x": 1607437370000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437390000, + "y": 0, + }, + Object { + "x": 1607437400000, + "y": 0, + }, + Object { + "x": 1607437410000, + "y": 0, + }, + Object { + "x": 1607437420000, + "y": 0, + }, + Object { + "x": 1607437430000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437450000, + "y": 0, + }, + Object { + "x": 1607437460000, + "y": 6, + }, + Object { + "x": 1607437470000, + "y": 12, + }, + Object { + "x": 1607437480000, + "y": 6, + }, + Object { + "x": 1607437490000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437510000, + "y": 0, + }, + Object { + "x": 1607437520000, + "y": 0, + }, + Object { + "x": 1607437530000, + "y": 0, + }, + Object { + "x": 1607437540000, + "y": 0, + }, + Object { + "x": 1607437550000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437570000, + "y": 6, + }, + Object { + "x": 1607437580000, + "y": 0, + }, + Object { + "x": 1607437590000, + "y": 0, + }, + Object { + "x": 1607437600000, + "y": 0, + }, + Object { + "x": 1607437610000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, + Object { + "x": 1607437630000, + "y": 0, + }, + Object { + "x": 1607437640000, + "y": 0, + }, + Object { + "x": 1607437650000, + "y": 0, + }, + Object { + "x": 1607437660000, + "y": 0, + }, + Object { + "x": 1607437670000, + "y": 0, + }, + ], + "previousPeriod": Array [ + Object { + "x": 1607436770000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436790000, + "y": 0, + }, + Object { + "x": 1607436800000, + "y": 24, + }, + Object { + "x": 1607436810000, + "y": 0, + }, + Object { + "x": 1607436820000, + "y": 0, + }, + Object { + "x": 1607436830000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 12, + }, + Object { + "x": 1607436850000, + "y": 0, + }, + Object { + "x": 1607436860000, + "y": 0, + }, + Object { + "x": 1607436870000, + "y": 0, + }, + Object { + "x": 1607436880000, + "y": 0, + }, + Object { + "x": 1607436890000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436910000, + "y": 12, + }, + Object { + "x": 1607436920000, + "y": 6, + }, + Object { + "x": 1607436930000, + "y": 6, + }, + Object { + "x": 1607436940000, + "y": 0, + }, + Object { + "x": 1607436950000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607436970000, + "y": 0, + }, + Object { + "x": 1607436980000, + "y": 0, + }, + Object { + "x": 1607436990000, + "y": 0, + }, + Object { + "x": 1607437000000, + "y": 0, + }, + Object { + "x": 1607437010000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437030000, + "y": 6, + }, + Object { + "x": 1607437040000, + "y": 18, + }, + Object { + "x": 1607437050000, + "y": 0, + }, + Object { + "x": 1607437060000, + "y": 0, + }, + Object { + "x": 1607437070000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437090000, + "y": 0, + }, + Object { + "x": 1607437100000, + "y": 0, + }, + Object { + "x": 1607437110000, + "y": 0, + }, + Object { + "x": 1607437120000, + "y": 0, + }, + Object { + "x": 1607437130000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437150000, + "y": 0, + }, + Object { + "x": 1607437160000, + "y": 36, + }, + Object { + "x": 1607437170000, + "y": 0, + }, + Object { + "x": 1607437180000, + "y": 0, + }, + Object { + "x": 1607437190000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 0, + }, + Object { + "x": 1607437210000, + "y": 0, + }, + Object { + "x": 1607437220000, + "y": 0, + }, + Object { + "x": 1607437230000, + "y": 0, + }, + Object { + "x": 1607437240000, + "y": 6, + }, + Object { + "x": 1607437250000, + "y": 0, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437270000, + "y": 0, + }, + Object { + "x": 1607437280000, + "y": 30, + }, + Object { + "x": 1607437290000, + "y": 6, + }, + Object { + "x": 1607437300000, + "y": 0, + }, + Object { + "x": 1607437310000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437330000, + "y": 0, + }, + Object { + "x": 1607437340000, + "y": 0, + }, + Object { + "x": 1607437350000, + "y": 0, + }, + Object { + "x": 1607437360000, + "y": 0, + }, + Object { + "x": 1607437370000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437390000, + "y": 0, + }, + Object { + "x": 1607437400000, + "y": 12, + }, + Object { + "x": 1607437410000, + "y": 6, + }, + Object { + "x": 1607437420000, + "y": 24, + }, + Object { + "x": 1607437430000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437450000, + "y": 0, + }, + Object { + "x": 1607437460000, + "y": 0, + }, + Object { + "x": 1607437470000, + "y": 0, + }, + Object { + "x": 1607437480000, + "y": 0, + }, + Object { + "x": 1607437490000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437510000, + "y": 0, + }, + Object { + "x": 1607437520000, + "y": 12, + }, + Object { + "x": 1607437530000, + "y": 30, + }, + Object { + "x": 1607437540000, + "y": 12, + }, + Object { + "x": 1607437550000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437570000, + "y": 0, + }, + Object { + "x": 1607437580000, + "y": 0, + }, + Object { + "x": 1607437590000, + "y": 0, + }, + Object { + "x": 1607437600000, + "y": 0, + }, + Object { + "x": 1607437610000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, + Object { + "x": 1607437630000, + "y": 0, + }, + Object { + "x": 1607437640000, + "y": 0, + }, + Object { + "x": 1607437650000, + "y": 6, + }, + Object { + "x": 1607437660000, + "y": 6, + }, + Object { + "x": 1607437670000, + "y": 0, + }, + ], +} +`; diff --git a/x-pack/test/apm_api_integration/tests/services/throughput.ts b/x-pack/test/apm_api_integration/tests/services/throughput.ts index 29f5d84d31b07d..787436ea37b05d 100644 --- a/x-pack/test/apm_api_integration/tests/services/throughput.ts +++ b/x-pack/test/apm_api_integration/tests/services/throughput.ts @@ -8,10 +8,15 @@ import expect from '@kbn/expect'; import qs from 'querystring'; import { first, last } from 'lodash'; +import moment from 'moment'; +import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { registry } from '../../common/registry'; +type ThroughputReturn = APIReturnType<'GET /api/apm/services/{serviceName}/throughput'>; + export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -29,17 +34,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { })}` ); expect(response.status).to.be(200); - expect(response.body.throughput.length).to.be(0); + expect(response.body.currentPeriod.length).to.be(0); + expect(response.body.previousPeriod.length).to.be(0); }); }); + let throughputResponse: ThroughputReturn; registry.when( 'Throughput when data is loaded', { config: 'basic', archives: [archiveName] }, () => { - let throughputResponse: { - throughput: Array<{ x: number; y: number | null }>; - }; before(async () => { const response = await supertest.get( `/api/apm/services/opbeans-java/throughput?${qs.stringify({ @@ -53,31 +57,98 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('returns some data', () => { - expect(throughputResponse.throughput.length).to.be.greaterThan(0); + expect(throughputResponse.currentPeriod.length).to.be.greaterThan(0); + expect(throughputResponse.previousPeriod.length).not.to.be.greaterThan(0); - const nonNullDataPoints = throughputResponse.throughput.filter(({ y }) => y !== null); + const nonNullDataPoints = throughputResponse.currentPeriod.filter(({ y }) => + isFiniteNumber(y) + ); expect(nonNullDataPoints.length).to.be.greaterThan(0); }); it('has the correct start date', () => { expectSnapshot( - new Date(first(throughputResponse.throughput)?.x ?? NaN).toISOString() + new Date(first(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() ).toMatchInline(`"2020-12-08T13:57:30.000Z"`); }); it('has the correct end date', () => { expectSnapshot( - new Date(last(throughputResponse.throughput)?.x ?? NaN).toISOString() + new Date(last(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() ).toMatchInline(`"2020-12-08T14:27:30.000Z"`); }); it('has the correct number of buckets', () => { - expectSnapshot(throughputResponse.throughput.length).toMatchInline(`61`); + expectSnapshot(throughputResponse.currentPeriod.length).toMatchInline(`61`); + }); + + it('has the correct throughput', () => { + expectSnapshot(throughputResponse.currentPeriod).toMatch(); + }); + } + ); + + registry.when( + 'Throughput when data is loaded with time comparison', + { config: 'basic', archives: [archiveName] }, + () => { + before(async () => { + const response = await supertest.get( + `/api/apm/services/opbeans-java/throughput?${qs.stringify({ + uiFilters: encodeURIComponent('{}'), + transactionType: 'request', + start: moment(metadata.end).subtract(15, 'minutes').toISOString(), + end: metadata.end, + comparisonStart: metadata.start, + comparisonEnd: moment(metadata.start).add(15, 'minutes').toISOString(), + })}` + ); + throughputResponse = response.body; + }); + + it('returns some data', () => { + expect(throughputResponse.currentPeriod.length).to.be.greaterThan(0); + expect(throughputResponse.previousPeriod.length).to.be.greaterThan(0); + + const currentPeriodNonNullDataPoints = throughputResponse.currentPeriod.filter(({ y }) => + isFiniteNumber(y) + ); + const previousPeriodNonNullDataPoints = throughputResponse.previousPeriod.filter(({ y }) => + isFiniteNumber(y) + ); + + expect(currentPeriodNonNullDataPoints.length).to.be.greaterThan(0); + expect(previousPeriodNonNullDataPoints.length).to.be.greaterThan(0); + }); + + it('has the correct start date', () => { + expectSnapshot( + new Date(first(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:12:50.000Z"`); + + expectSnapshot( + new Date(first(throughputResponse.previousPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:12:50.000Z"`); + }); + + it('has the correct end date', () => { + expectSnapshot( + new Date(last(throughputResponse.currentPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:27:50.000Z"`); + + expectSnapshot( + new Date(last(throughputResponse.previousPeriod)?.x ?? NaN).toISOString() + ).toMatchInline(`"2020-12-08T14:27:50.000Z"`); + }); + + it('has the correct number of buckets', () => { + expectSnapshot(throughputResponse.currentPeriod.length).toMatchInline(`91`); + expectSnapshot(throughputResponse.previousPeriod.length).toMatchInline(`91`); }); it('has the correct throughput', () => { - expectSnapshot(throughputResponse.throughput).toMatch(); + expectSnapshot(throughputResponse).toMatch(); }); } ); diff --git a/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/transactions_groups_comparison_statistics.snap b/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/transactions_groups_comparison_statistics.snap new file mode 100644 index 00000000000000..739ff5a080d76c --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/transactions/__snapshots__/transactions_groups_comparison_statistics.snap @@ -0,0 +1,517 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`APM API tests basic apm_8.0.0 Transaction groups comparison statistics when data is loaded returns the correct data 1`] = ` +Array [ + Object { + "x": 1607435820000, + "y": null, + }, + Object { + "x": 1607435880000, + "y": 69429, + }, + Object { + "x": 1607435940000, + "y": 8071285, + }, + Object { + "x": 1607436000000, + "y": 31949, + }, + Object { + "x": 1607436060000, + "y": null, + }, + Object { + "x": 1607436120000, + "y": 47755, + }, + Object { + "x": 1607436180000, + "y": null, + }, + Object { + "x": 1607436240000, + "y": 35403, + }, + Object { + "x": 1607436300000, + "y": null, + }, + Object { + "x": 1607436360000, + "y": null, + }, + Object { + "x": 1607436420000, + "y": null, + }, + Object { + "x": 1607436480000, + "y": 48137, + }, + Object { + "x": 1607436540000, + "y": null, + }, + Object { + "x": 1607436600000, + "y": 35457, + }, + Object { + "x": 1607436660000, + "y": null, + }, + Object { + "x": 1607436720000, + "y": null, + }, + Object { + "x": 1607436780000, + "y": null, + }, + Object { + "x": 1607436840000, + "y": null, + }, + Object { + "x": 1607436900000, + "y": null, + }, + Object { + "x": 1607436960000, + "y": 30501, + }, + Object { + "x": 1607437020000, + "y": null, + }, + Object { + "x": 1607437080000, + "y": null, + }, + Object { + "x": 1607437140000, + "y": null, + }, + Object { + "x": 1607437200000, + "y": 46937.5, + }, + Object { + "x": 1607437260000, + "y": null, + }, + Object { + "x": 1607437320000, + "y": null, + }, + Object { + "x": 1607437380000, + "y": null, + }, + Object { + "x": 1607437440000, + "y": null, + }, + Object { + "x": 1607437500000, + "y": null, + }, + Object { + "x": 1607437560000, + "y": null, + }, + Object { + "x": 1607437620000, + "y": null, + }, +] +`; + +exports[`APM API tests basic apm_8.0.0 Transaction groups comparison statistics when data is loaded returns the correct data 2`] = ` +Array [ + Object { + "x": 1607435820000, + "y": 0, + }, + Object { + "x": 1607435880000, + "y": 1, + }, + Object { + "x": 1607435940000, + "y": 2, + }, + Object { + "x": 1607436000000, + "y": 1, + }, + Object { + "x": 1607436060000, + "y": 0, + }, + Object { + "x": 1607436120000, + "y": 1, + }, + Object { + "x": 1607436180000, + "y": 0, + }, + Object { + "x": 1607436240000, + "y": 4, + }, + Object { + "x": 1607436300000, + "y": 0, + }, + Object { + "x": 1607436360000, + "y": 0, + }, + Object { + "x": 1607436420000, + "y": 0, + }, + Object { + "x": 1607436480000, + "y": 2, + }, + Object { + "x": 1607436540000, + "y": 0, + }, + Object { + "x": 1607436600000, + "y": 1, + }, + Object { + "x": 1607436660000, + "y": 0, + }, + Object { + "x": 1607436720000, + "y": 0, + }, + Object { + "x": 1607436780000, + "y": 0, + }, + Object { + "x": 1607436840000, + "y": 0, + }, + Object { + "x": 1607436900000, + "y": 0, + }, + Object { + "x": 1607436960000, + "y": 2, + }, + Object { + "x": 1607437020000, + "y": 0, + }, + Object { + "x": 1607437080000, + "y": 0, + }, + Object { + "x": 1607437140000, + "y": 0, + }, + Object { + "x": 1607437200000, + "y": 2, + }, + Object { + "x": 1607437260000, + "y": 0, + }, + Object { + "x": 1607437320000, + "y": 0, + }, + Object { + "x": 1607437380000, + "y": 0, + }, + Object { + "x": 1607437440000, + "y": 0, + }, + Object { + "x": 1607437500000, + "y": 0, + }, + Object { + "x": 1607437560000, + "y": 0, + }, + Object { + "x": 1607437620000, + "y": 0, + }, +] +`; + +exports[`APM API tests basic apm_8.0.0 Transaction groups comparison statistics when data is loaded returns the correct data 3`] = ` +Array [ + Object { + "x": 1607435820000, + "y": null, + }, + Object { + "x": 1607435880000, + "y": 0, + }, + Object { + "x": 1607435940000, + "y": 0, + }, + Object { + "x": 1607436000000, + "y": 0, + }, + Object { + "x": 1607436060000, + "y": null, + }, + Object { + "x": 1607436120000, + "y": 0, + }, + Object { + "x": 1607436180000, + "y": null, + }, + Object { + "x": 1607436240000, + "y": 0, + }, + Object { + "x": 1607436300000, + "y": null, + }, + Object { + "x": 1607436360000, + "y": null, + }, + Object { + "x": 1607436420000, + "y": null, + }, + Object { + "x": 1607436480000, + "y": 0, + }, + Object { + "x": 1607436540000, + "y": null, + }, + Object { + "x": 1607436600000, + "y": 0, + }, + Object { + "x": 1607436660000, + "y": null, + }, + Object { + "x": 1607436720000, + "y": null, + }, + Object { + "x": 1607436780000, + "y": null, + }, + Object { + "x": 1607436840000, + "y": null, + }, + Object { + "x": 1607436900000, + "y": null, + }, + Object { + "x": 1607436960000, + "y": 0, + }, + Object { + "x": 1607437020000, + "y": null, + }, + Object { + "x": 1607437080000, + "y": null, + }, + Object { + "x": 1607437140000, + "y": null, + }, + Object { + "x": 1607437200000, + "y": 0.5, + }, + Object { + "x": 1607437260000, + "y": null, + }, + Object { + "x": 1607437320000, + "y": null, + }, + Object { + "x": 1607437380000, + "y": null, + }, + Object { + "x": 1607437440000, + "y": null, + }, + Object { + "x": 1607437500000, + "y": null, + }, + Object { + "x": 1607437560000, + "y": null, + }, + Object { + "x": 1607437620000, + "y": null, + }, +] +`; + +exports[`APM API tests basic apm_8.0.0 Transaction groups comparison statistics when data is loaded returns the correct for latency aggregation 99th percentile 1`] = ` +Array [ + Object { + "x": 1607435820000, + "y": null, + }, + Object { + "x": 1607435880000, + "y": 69429, + }, + Object { + "x": 1607435940000, + "y": 8198285, + }, + Object { + "x": 1607436000000, + "y": 31949, + }, + Object { + "x": 1607436060000, + "y": null, + }, + Object { + "x": 1607436120000, + "y": 47755, + }, + Object { + "x": 1607436180000, + "y": null, + }, + Object { + "x": 1607436240000, + "y": 73411, + }, + Object { + "x": 1607436300000, + "y": null, + }, + Object { + "x": 1607436360000, + "y": null, + }, + Object { + "x": 1607436420000, + "y": null, + }, + Object { + "x": 1607436480000, + "y": 55116, + }, + Object { + "x": 1607436540000, + "y": null, + }, + Object { + "x": 1607436600000, + "y": 35457, + }, + Object { + "x": 1607436660000, + "y": null, + }, + Object { + "x": 1607436720000, + "y": null, + }, + Object { + "x": 1607436780000, + "y": null, + }, + Object { + "x": 1607436840000, + "y": null, + }, + Object { + "x": 1607436900000, + "y": null, + }, + Object { + "x": 1607436960000, + "y": 46040, + }, + Object { + "x": 1607437020000, + "y": null, + }, + Object { + "x": 1607437080000, + "y": null, + }, + Object { + "x": 1607437140000, + "y": null, + }, + Object { + "x": 1607437200000, + "y": 82486, + }, + Object { + "x": 1607437260000, + "y": null, + }, + Object { + "x": 1607437320000, + "y": null, + }, + Object { + "x": 1607437380000, + "y": null, + }, + Object { + "x": 1607437440000, + "y": null, + }, + Object { + "x": 1607437500000, + "y": null, + }, + Object { + "x": 1607437560000, + "y": null, + }, + Object { + "x": 1607437620000, + "y": null, + }, +] +`; diff --git a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_comparison_statistics.ts b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_comparison_statistics.ts new file mode 100644 index 00000000000000..414e2189a63fe4 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_comparison_statistics.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import url from 'url'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; +import archives from '../../common/fixtures/es_archiver/archives_metadata'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { registry } from '../../common/registry'; +import { removeEmptyCoordinates, roundNumber } from '../../utils'; + +type TransactionsGroupsComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const archiveName = 'apm_8.0.0'; + const { start, end } = archives[archiveName]; + const transactionNames = ['DispatcherServlet#doGet', 'APIRestController#customers']; + + registry.when( + 'Transaction groups comparison statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/comparison_statistics`, + query: { + start, + end, + uiFilters: '{}', + numBuckets: 20, + latencyAggregationType: 'avg', + transactionType: 'request', + transactionNames: JSON.stringify(transactionNames), + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.empty(); + }); + } + ); + + registry.when( + 'Transaction groups comparison statistics when data is loaded', + { config: 'basic', archives: [archiveName] }, + () => { + it('returns the correct data', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/comparison_statistics`, + query: { + start, + end, + uiFilters: '{}', + numBuckets: 20, + transactionType: 'request', + latencyAggregationType: 'avg', + transactionNames: JSON.stringify(transactionNames), + }, + }) + ); + + expect(response.status).to.be(200); + + const transactionsGroupsComparisonStatistics = response.body as TransactionsGroupsComparisonStatistics; + + expect(Object.keys(transactionsGroupsComparisonStatistics).length).to.be.eql( + transactionNames.length + ); + + transactionNames.map((transactionName) => { + expect(transactionsGroupsComparisonStatistics[transactionName]).not.to.be.empty(); + }); + + const { latency, throughput, errorRate, impact } = transactionsGroupsComparisonStatistics[ + transactionNames[0] + ]; + + expect(removeEmptyCoordinates(latency).length).to.be.greaterThan(0); + expectSnapshot(latency).toMatch(); + + expect(removeEmptyCoordinates(throughput).length).to.be.greaterThan(0); + expectSnapshot(throughput).toMatch(); + + expect(removeEmptyCoordinates(errorRate).length).to.be.greaterThan(0); + expectSnapshot(errorRate).toMatch(); + + expectSnapshot(roundNumber(impact)).toMatchInline(`"93.93"`); + }); + + it('returns the correct for latency aggregation 99th percentile', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/comparison_statistics`, + query: { + start, + end, + uiFilters: '{}', + numBuckets: 20, + transactionType: 'request', + latencyAggregationType: 'p99', + transactionNames: JSON.stringify(transactionNames), + }, + }) + ); + + expect(response.status).to.be(200); + + const transactionsGroupsComparisonStatistics = response.body as TransactionsGroupsComparisonStatistics; + + expect(Object.keys(transactionsGroupsComparisonStatistics).length).to.be.eql( + transactionNames.length + ); + + transactionNames.map((transactionName) => { + expect(transactionsGroupsComparisonStatistics[transactionName]).not.to.be.empty(); + }); + + const { latency, throughput, errorRate } = transactionsGroupsComparisonStatistics[ + transactionNames[0] + ]; + expect(removeEmptyCoordinates(latency).length).to.be.greaterThan(0); + expectSnapshot(latency).toMatch(); + + expect(removeEmptyCoordinates(throughput).length).to.be.greaterThan(0); + expect(removeEmptyCoordinates(errorRate).length).to.be.greaterThan(0); + }); + + it('returns empty when transaction name is not found', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/comparison_statistics`, + query: { + start, + end, + uiFilters: '{}', + numBuckets: 20, + transactionType: 'request', + latencyAggregationType: 'avg', + transactionNames: JSON.stringify(['foo']), + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.empty(); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_overview.ts b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_overview.ts deleted file mode 100644 index 807373f5828640..00000000000000 --- a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_overview.ts +++ /dev/null @@ -1,273 +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 expect from '@kbn/expect'; -import { pick, uniqBy, sortBy } from 'lodash'; -import url from 'url'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import archives from '../../common/fixtures/es_archiver/archives_metadata'; -import { registry } from '../../common/registry'; - -export default function ApiTest({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - - const archiveName = 'apm_8.0.0'; - const { start, end } = archives[archiveName]; - - registry.when( - 'Transaction groups overview when data is not loaded', - { config: 'basic', archives: [] }, - () => { - it('handles the empty state', async () => { - const response = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - latencyAggregationType: 'avg', - transactionType: 'request', - }, - }) - ); - - expect(response.status).to.be(200); - expect(response.body).to.eql({ - totalTransactionGroups: 0, - transactionGroups: [], - isAggregationAccurate: true, - }); - }); - } - ); - - registry.when( - 'Top transaction groups when data is loaded', - { config: 'basic', archives: [archiveName] }, - () => { - it('returns the correct data', async () => { - const response = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - expect(response.status).to.be(200); - - expectSnapshot(response.body.totalTransactionGroups).toMatchInline(`12`); - - expectSnapshot(response.body.transactionGroups.map((group: any) => group.name)) - .toMatchInline(` - Array [ - "DispatcherServlet#doGet", - "APIRestController#customers", - "APIRestController#order", - "APIRestController#stats", - "APIRestController#customerWhoBought", - ] - `); - - expectSnapshot(response.body.transactionGroups.map((group: any) => group.impact)) - .toMatchInline(` - Array [ - 100, - 1.43059146953109, - 0.953769516915408, - 0.905498741191481, - 0.894989230293471, - ] - `); - - const firstItem = response.body.transactionGroups[0]; - - expectSnapshot( - pick(firstItem, 'name', 'latency.value', 'throughput.value', 'errorRate.value', 'impact') - ).toMatchInline(` - Object { - "errorRate": Object { - "value": 0.0625, - }, - "impact": 100, - "latency": Object { - "value": 1044995.1875, - }, - "name": "DispatcherServlet#doGet", - "throughput": Object { - "value": 0.533333333333333, - }, - } - `); - - expectSnapshot( - firstItem.latency.timeseries.filter(({ y }: any) => y > 0).length - ).toMatchInline(`9`); - - expectSnapshot( - firstItem.throughput.timeseries.filter(({ y }: any) => y > 0).length - ).toMatchInline(`9`); - - expectSnapshot( - firstItem.errorRate.timeseries.filter(({ y }: any) => y > 0).length - ).toMatchInline(`1`); - }); - - it('sorts items in the correct order', async () => { - const descendingResponse = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - expect(descendingResponse.status).to.be(200); - - const descendingOccurrences = descendingResponse.body.transactionGroups.map( - (item: any) => item.impact - ); - - expect(descendingOccurrences).to.eql(sortBy(descendingOccurrences.concat()).reverse()); - - const ascendingResponse = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - const ascendingOccurrences = ascendingResponse.body.transactionGroups.map( - (item: any) => item.impact - ); - - expect(ascendingOccurrences).to.eql(sortBy(ascendingOccurrences.concat()).reverse()); - }); - - it('sorts items by the correct field', async () => { - const response = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size: 5, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'latency', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - expect(response.status).to.be(200); - - const latencies = response.body.transactionGroups.map((group: any) => group.latency.value); - - expect(latencies).to.eql(sortBy(latencies.concat()).reverse()); - }); - - it('paginates through the items', async () => { - const size = 1; - - const firstPage = await supertest.get( - url.format({ - pathname: `/api/apm/services/opbeans-java/transactions/groups/overview`, - query: { - start, - end, - uiFilters: '{}', - size, - numBuckets: 20, - pageIndex: 0, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - expect(firstPage.status).to.eql(200); - - const totalItems = firstPage.body.totalTransactionGroups; - - const pages = Math.floor(totalItems / size); - - const items = await new Array(pages) - .fill(undefined) - .reduce(async (prevItemsPromise, _, pageIndex) => { - const prevItems = await prevItemsPromise; - - const thisPage = await supertest.get( - url.format({ - pathname: '/api/apm/services/opbeans-java/transactions/groups/overview', - query: { - start, - end, - uiFilters: '{}', - size, - numBuckets: 20, - pageIndex, - sortDirection: 'desc', - sortField: 'impact', - transactionType: 'request', - latencyAggregationType: 'avg', - }, - }) - ); - - return prevItems.concat(thisPage.body.transactionGroups); - }, Promise.resolve([])); - - expect(items.length).to.eql(totalItems); - - expect(uniqBy(items, 'name').length).to.eql(totalItems); - }); - } - ); -} diff --git a/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_primary_statistics.ts b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_primary_statistics.ts new file mode 100644 index 00000000000000..7d8417bc5bf63a --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/transactions/transactions_groups_primary_statistics.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { pick, sum } from 'lodash'; +import url from 'url'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import archives from '../../common/fixtures/es_archiver/archives_metadata'; +import { registry } from '../../common/registry'; + +type TransactionsGroupsPrimaryStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const archiveName = 'apm_8.0.0'; + const { start, end } = archives[archiveName]; + + registry.when( + 'Transaction groups primary statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/primary_statistics`, + query: { + start, + end, + uiFilters: '{}', + latencyAggregationType: 'avg', + transactionType: 'request', + }, + }) + ); + + expect(response.status).to.be(200); + const transctionsGroupsPrimaryStatistics = response.body as TransactionsGroupsPrimaryStatistics; + expect(transctionsGroupsPrimaryStatistics.transactionGroups).to.empty(); + expect(transctionsGroupsPrimaryStatistics.isAggregationAccurate).to.be(true); + }); + } + ); + + registry.when( + 'Transaction groups primary statistics when data is loaded', + { config: 'basic', archives: [archiveName] }, + () => { + it('returns the correct data', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/primary_statistics`, + query: { + start, + end, + uiFilters: '{}', + transactionType: 'request', + latencyAggregationType: 'avg', + }, + }) + ); + + expect(response.status).to.be(200); + + const transctionsGroupsPrimaryStatistics = response.body as TransactionsGroupsPrimaryStatistics; + + expectSnapshot( + transctionsGroupsPrimaryStatistics.transactionGroups.map((group: any) => group.name) + ).toMatchInline(` + Array [ + "DispatcherServlet#doGet", + "APIRestController#customerWhoBought", + "APIRestController#order", + "APIRestController#customer", + "ResourceHttpRequestHandler", + "APIRestController#customers", + "APIRestController#stats", + "APIRestController#topProducts", + "APIRestController#orders", + "APIRestController#product", + "APIRestController#products", + "DispatcherServlet#doPost", + ] + `); + + const impacts = transctionsGroupsPrimaryStatistics.transactionGroups.map( + (group: any) => group.impact + ); + expectSnapshot(impacts).toMatchInline(` + Array [ + 93.9295870910491, + 0.850308244392878, + 0.905514602241759, + 0.699947181217412, + 0.143906183235671, + 1.35334507158962, + 0.860178761411346, + 0.476138685202191, + 0.446650726277923, + 0.262571482598846, + 0.062116281544223, + 0.00973568923904662, + ] + `); + + expect(Math.round(sum(impacts))).to.eql(100); + + const firstItem = transctionsGroupsPrimaryStatistics.transactionGroups[0]; + + expectSnapshot(pick(firstItem, 'name', 'latency', 'throughput', 'errorRate', 'impact')) + .toMatchInline(` + Object { + "errorRate": 0.0625, + "impact": 93.9295870910491, + "latency": 1044995.1875, + "name": "DispatcherServlet#doGet", + "throughput": 0.533333333333333, + } + `); + }); + + it('returns the correct data for latency aggregation 99th percentile', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/transactions/groups/primary_statistics`, + query: { + start, + end, + uiFilters: '{}', + transactionType: 'request', + latencyAggregationType: 'p99', + }, + }) + ); + + expect(response.status).to.be(200); + + const transctionsGroupsPrimaryStatistics = response.body as TransactionsGroupsPrimaryStatistics; + + const firstItem = transctionsGroupsPrimaryStatistics.transactionGroups[0]; + expectSnapshot(firstItem.latency).toMatchInline(`8198285`); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/utils.ts b/x-pack/test/apm_api_integration/utils.ts new file mode 100644 index 00000000000000..0fb99e2aa3c7cb --- /dev/null +++ b/x-pack/test/apm_api_integration/utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Coordinate } from '../../plugins/apm/typings/timeseries'; +import { isFiniteNumber } from '../../plugins/apm/common/utils/is_finite_number'; +import { Maybe } from '../../plugins/apm/typings/common'; + +export function roundNumber(num: Maybe) { + return isFiniteNumber(num) ? num.toPrecision(4) : ''; +} + +export function removeEmptyCoordinates(coordinates: Coordinate[]) { + return coordinates.filter(({ y }) => isFiniteNumber(y)); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts index fc391b81b279ad..f908a369b46d71 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts @@ -10,7 +10,15 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -42,7 +50,6 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(204) .send(); - expect(comment).to.eql({}); }); @@ -77,5 +84,67 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(404); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('deletes a comment from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .delete( + `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}?subCaseID=${ + caseInfo.subCase!.id + }` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + const { body } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + ); + expect(body.length).to.eql(0); + }); + + it('deletes all comments from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + let { body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + ); + expect(allComments.length).to.eql(2); + + await supertest + .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + ({ body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + )); + + // no comments for the sub case + expect(allComments.length).to.eql(0); + + ({ body: allComments } = await supertest.get(`${CASES_URL}/${caseInfo.id}/comments`)); + + // no comments for the collection + expect(allComments.length).to.eql(0); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts index 824ea40d38ace7..585333291111eb 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts @@ -9,9 +9,17 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -102,5 +110,35 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('finds comments for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: subCaseComments }: { body: CommentsResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts new file mode 100644 index 00000000000000..1af16f9e54563c --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { CommentType } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_all_comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should get multiple comments for a single case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(comments.length).to.eql(2); + }); + + it('should get comments from a case and its sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?includeSubCaseComments=true`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should get comments from a sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.subCase!.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should not find any comments for an invalid case id', async () => { + const { body } = await supertest + .get(`${CASES_URL}/fake-id/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(0); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts index 89efc927de5e3d..389ec3f088f95b 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts @@ -10,7 +10,13 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { CommentResponse, CommentType } from '../../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -18,10 +24,15 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('get_comment', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should get a comment', async () => { @@ -45,6 +56,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(comment).to.eql(patchedCase.comments[0]); }); + + it('should get a sub case comment', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const { body: comment }: { body: CommentResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}`) + .expect(200); + expect(comment.type).to.be(CommentType.generatedAlert); + }); + it('unhappy path - 404s when comment is not there', async () => { await supertest .get(`${CASES_URL}/fake-id/comments/fake-comment`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts index 73cce973eef94a..2250b481c37291 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts @@ -10,14 +10,25 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { + CollectionWithSubCaseResponse, + CommentType, +} from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCommentUserReq, postCommentAlertReq, } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -31,6 +42,79 @@ export default ({ getService }: FtrProviderContext): void => { await deleteCasesUserActions(es); }); + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('patches a comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const { + body: patchedSubCase, + }: { body: CollectionWithSubCaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const { body: patchedSubCaseUpdatedComment } = await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedSubCase.subCase!.comments![1].id, + version: patchedSubCase.subCase!.comments![1].version, + comment: newComment, + type: CommentType.user, + }) + .expect(200); + + expect(patchedSubCaseUpdatedComment.subCase.comments.length).to.be(2); + expect(patchedSubCaseUpdatedComment.subCase.comments[0].type).to.be( + CommentType.generatedAlert + ); + expect(patchedSubCaseUpdatedComment.subCase.comments[1].type).to.be(CommentType.user); + expect(patchedSubCaseUpdatedComment.subCase.comments[1].comment).to.be(newComment); + }); + + it('fails to update the generated alert comment type', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.subCase!.comments![0].id, + version: caseInfo.subCase!.comments![0].version, + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }) + .expect(400); + }); + + it('fails to update the generated alert comment by using another generated alert comment', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.subCase!.comments![0].id, + version: caseInfo.subCase!.comments![0].version, + type: CommentType.generatedAlert, + alerts: [{ _id: 'id1' }], + index: 'test-index', + }) + .expect(400); + }); + }); + it('should patch a comment', async () => { const { body: postedCase } = await supertest .post(CASES_URL) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts index 087eb79dde7d2a..1ce011985d9e63 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -11,14 +11,24 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../../plugins/security_solution/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCommentUserReq, postCommentAlertReq, + postCollectionReq, + postCommentGenAlertReq, } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; import { createSignalsIndex, deleteSignalsIndex, @@ -209,6 +219,34 @@ export default ({ getService }: FtrProviderContext): void => { .expect(400); }); + it('400s when adding an alert to a collection case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(400); + }); + + it('400s when adding a generated alert to an individual case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentGenAlertReq) + .expect(400); + }); + describe('alerts', () => { beforeEach(async () => { await esArchiver.load('auditbeat/hosts'); @@ -321,5 +359,37 @@ export default ({ getService }: FtrProviderContext): void => { expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); }); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('posts a new comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // create another sub case just to make sure we get the right comments + await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: subCaseComments }: { body: CommentsResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts index 41a9c822efda73..a2bc0acbcf17cc 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts @@ -8,9 +8,17 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq, findCasesResp } from '../../../common/lib/mock'; -import { deleteCases, deleteComments, deleteCasesUserActions } from '../../../common/lib/utils'; +import { + deleteAllCaseItems, + createSubCase, + setStatus, + CreateSubCaseResp, + createCaseAction, + deleteCaseAction, +} from '../../../common/lib/utils'; +import { CasesFindResponse, CaseStatuses, CaseType } from '../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -18,9 +26,7 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('find_cases', () => { afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should return empty response', async () => { @@ -242,6 +248,130 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.count_in_progress_cases).to.eql(1); }); + describe('stats with sub cases', () => { + let collection: CreateSubCaseResp; + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + beforeEach(async () => { + collection = await createSubCase({ supertest, actionID }); + + const [, , { body: toCloseCase }] = await Promise.all([ + setStatus({ + supertest, + cases: [ + { + id: collection.newSubCaseInfo.subCase!.id, + version: collection.newSubCaseInfo.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + ]); + + await setStatus({ + supertest, + cases: [ + { + id: toCloseCase.id, + version: toCloseCase.version, + status: CaseStatuses.closed, + }, + ], + type: 'case', + }); + }); + it('correctly counts stats without using a filter', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .expect(200); + + expect(body.total).to.eql(3); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for open cases', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&status=open`) + .expect(200); + + expect(body.total).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for individual cases', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.individual}`) + .expect(200); + + expect(body.total).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}`) + .expect(200); + + expect(body.total).to.eql(1); + expect(body.cases[0].subCases?.length).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection and open cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); + const { body }: { body: CasesFindResponse } = await supertest + .get( + `${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}&status=${CaseStatuses.open}` + ) + .expect(200); + + expect(body.total).to.eql(1); + expect(body.cases[0].subCases?.length).to.eql(1); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats including a collection without sub cases', async () => { + // delete the sub case on the collection so that it doesn't have any sub cases + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCase!.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .expect(200); + + expect(body.total).to.eql(3); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + }); + it('unhappy path - 400s when bad query supplied', async () => { await supertest .get(`${CASES_URL}/_find?perPage=true`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts index 1b51ec9ba1171f..dcc49152e4db8c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -10,14 +10,17 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../plugins/case/common/constants'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../plugins/security_solution/common/constants'; -import { CommentType } from '../../../../../plugins/case/common/api'; +import { CaseType, CommentType } from '../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCaseResp, + postCollectionReq, + postCommentAlertReq, + postCommentUserReq, removeServerGeneratedPropertiesFromCase, } from '../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions } from '../../../common/lib/utils'; +import { deleteAllCaseItems } from '../../../common/lib/utils'; import { createSignalsIndex, deleteSignalsIndex, @@ -38,8 +41,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('patch_cases', () => { afterEach(async () => { - await deleteCases(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should patch a case', async () => { @@ -127,6 +129,106 @@ export default ({ getService }: FtrProviderContext): void => { .expect(404); }); + it('should 400 and not allow converting a collection back to an individual case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + type: CaseType.individual, + }, + ], + }) + .expect(400); + }); + + it('should allow converting an individual case to a collection when it does not have alerts', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }) + .expect(200); + }); + + it('should 400 when attempting to update an individual case to a collection when it has alerts attached to it', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }) + .expect(400); + }); + + it("should 400 when attempting to update a collection case's status", async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(400); + }); + it('unhappy path - 406s when excess data sent', async () => { const { body: postedCase } = await supertest .post(CASES_URL) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index ef7c57b3b47499..735c079c7b850a 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -80,7 +80,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -143,7 +149,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -196,7 +208,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); @@ -268,7 +286,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts new file mode 100644 index 00000000000000..537afbe8250682 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + CASES_URL, + SUB_CASES_PATCH_DEL_URL, +} from '../../../../../../plugins/case/common/constants'; +import { postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { CollectionWithSubCaseResponse } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should delete a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + expect(caseInfo.subCase?.id).to.not.eql(undefined); + + const { body: subCase } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .send() + .expect(200); + + expect(subCase.id).to.not.eql(undefined); + + const { body } = await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${subCase.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + expect(body).to.eql({}); + await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .send() + .expect(404); + }); + + it(`should delete a sub case's comments when that case gets deleted`, async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + expect(caseInfo.subCase?.id).to.not.eql(undefined); + + // there should be two comments on the sub case now + const { + body: patchedCaseWithSubCase, + }: { body: CollectionWithSubCaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .query({ subCaseID: caseInfo.subCase!.id }) + .send(postCommentUserReq) + .expect(200); + + const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ + patchedCaseWithSubCase.subCase!.comments![1].id + }`; + // make sure we can get the second comment + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); + + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCase!.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(404); + }); + + it('unhappy path - 404s when sub case id is invalid', async () => { + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["fake-id"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts new file mode 100644 index 00000000000000..3463b372509809 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { findSubCasesResp, postCollectionReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + setStatus, +} from '../../../../common/lib/utils'; +import { getSubCasesUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { + CaseResponse, + CaseStatuses, + SubCasesFindResponse, +} from '../../../../../../plugins/case/common/api'; +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('find_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should not find any sub cases when none exist', async () => { + const { body: caseResp }: { body: CaseResponse } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + const { body: findSubCases } = await supertest + .get(`${getSubCasesUrl(caseResp.id)}/_find`) + .expect(200); + + expect(findSubCases).to.eql({ + page: 1, + per_page: 20, + total: 0, + subCases: [], + count_open_cases: 0, + count_closed_cases: 0, + count_in_progress_cases: 0, + }); + }); + + it('should return a sub cases with comment stats', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find`) + .expect(200); + + expect(body).to.eql({ + ...findSubCasesResp, + total: 1, + // find should not return the comments themselves only the stats + subCases: [{ ...caseInfo.subCase!, comments: [], totalComment: 1, totalAlerts: 2 }], + count_open_cases: 1, + }); + }); + + it('should return multiple sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const subCase2Resp = await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find`) + .expect(200); + + expect(body).to.eql({ + ...findSubCasesResp, + total: 2, + // find should not return the comments themselves only the stats + subCases: [ + { + // there should only be 1 closed sub case + ...subCase2Resp.modifiedSubCases![0], + comments: [], + totalComment: 1, + totalAlerts: 2, + status: CaseStatuses.closed, + }, + { + ...subCase2Resp.newSubCaseInfo.subCase, + comments: [], + totalComment: 1, + totalAlerts: 2, + }, + ], + count_open_cases: 1, + count_closed_cases: 1, + }); + }); + + it('should only return open when filtering for open', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses.open}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses.open); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should only return closed when filtering for closed', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses.closed}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should only return in progress when filtering for in progress', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + const { newSubCaseInfo: secondSub } = await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + await setStatus({ + supertest, + cases: [ + { + id: secondSub.subCase!.id, + version: secondSub.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses['in-progress']}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses['in-progress']); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(0); + expect(body.count_in_progress_cases).to.be(1); + }); + + it('should sort on createdAt field in descending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=createdAt&sortOrder=desc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.open); + expect(body.subCases[1].status).to.be(CaseStatuses.closed); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should sort on createdAt field in ascending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=createdAt&sortOrder=asc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.subCases[1].status).to.be(CaseStatuses.open); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should sort on updatedAt field in ascending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + const { newSubCaseInfo: secondSub } = await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + await setStatus({ + supertest, + cases: [ + { + id: secondSub.subCase!.id, + version: secondSub.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=updatedAt&sortOrder=asc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.subCases[1].status).to.be(CaseStatuses['in-progress']); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(0); + expect(body.count_in_progress_cases).to.be(1); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts new file mode 100644 index 00000000000000..cd5a1ed85742f1 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + commentsResp, + postCommentAlertReq, + removeServerGeneratedPropertiesFromComments, + removeServerGeneratedPropertiesFromSubCase, + subCaseResp, +} from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + defaultCreateSubComment, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { + getCaseCommentsUrl, + getSubCaseDetailsUrl, +} from '../../../../../../plugins/case/common/api/helpers'; +import { + AssociationType, + CollectionWithSubCaseResponse, + SubCaseResponse, +} from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_sub_case', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return a case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( + commentsResp({ + comments: [{ comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }], + associationType: AssociationType.subCase, + }) + ); + + expect(removeServerGeneratedPropertiesFromSubCase(body)).to.eql( + subCaseResp({ id: body.id, totalComment: 1, totalAlerts: 2 }) + ); + }); + + it('should return the correct number of alerts with multiple types of alerts', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest + .post(getCaseCommentsUrl(caseInfo.id)) + .query({ subCaseID: caseInfo.subCase!.id }) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + const { body }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( + commentsResp({ + comments: [ + { comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }, + { + comment: postCommentAlertReq, + id: singleAlert.subCase!.comments![1].id, + }, + ], + associationType: AssociationType.subCase, + }) + ); + + expect(removeServerGeneratedPropertiesFromSubCase(body)).to.eql( + subCaseResp({ id: body.id, totalComment: 2, totalAlerts: 3 }) + ); + }); + + it('unhappy path - 404s when case is not there', async () => { + await supertest + .get(getSubCaseDetailsUrl('fake-case-id', 'fake-sub-case-id')) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts new file mode 100644 index 00000000000000..66422724b5677e --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../../plugins/case/common/constants'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + setStatus, +} from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { CaseStatuses, SubCaseResponse } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should update the status of a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + await setStatus({ + supertest, + cases: [ + { + id: caseInfo.subCase!.id, + version: caseInfo.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + const { body: subCase }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .expect(200); + + expect(subCase.status).to.eql(CaseStatuses['in-progress']); + }); + + it('404s when sub case id is invalid', async () => { + await supertest + .patch(`${SUB_CASES_PATCH_DEL_URL}`) + .set('kbn-xsrf', 'true') + .send({ + subCases: [ + { + id: 'fake-id', + version: 'blah', + status: CaseStatuses.open, + }, + ], + }) + .expect(404); + }); + + it('406s when updating invalid fields for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + await supertest + .patch(`${SUB_CASES_PATCH_DEL_URL}`) + .set('kbn-xsrf', 'true') + .send({ + subCases: [ + { + id: caseInfo.subCase!.id, + version: caseInfo.subCase!.version, + type: 'blah', + }, + ], + }) + .expect(406); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index d83d87da1e7afe..b771da84d4360c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -10,7 +10,12 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../../plugins/case/common/constants'; import { CommentType } from '../../../../../../plugins/case/common/api'; -import { defaultUser, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + userActionPostResp, + defaultUser, + postCaseReq, + postCommentUserReq, +} from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, @@ -73,7 +78,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); - expect(body[0].new_value).to.eql(JSON.stringify(postCaseReq)); + expect(JSON.parse(body[0].new_value)).to.eql(userActionPostResp); }); it(`on close case, user action: 'update' should be called with actionFields: ['status']`, async () => { @@ -147,10 +152,18 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['connector']); expect(body[1].action).to.eql('update'); - expect(body[1].old_value).to.eql(`{"id":"none","name":"none","type":".none","fields":null}`); - expect(body[1].new_value).to.eql( - `{"id":"123","name":"Connector","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}}` - ); + expect(JSON.parse(body[1].old_value)).to.eql({ + id: 'none', + name: 'none', + type: '.none', + fields: null, + }); + expect(JSON.parse(body[1].new_value)).to.eql({ + id: '123', + name: 'Connector', + type: '.jira', + fields: { issueType: 'Task', priority: 'High', parent: null }, + }); }); it(`on update tags, user action: 'add' and 'delete' should be called with actionFields: ['tags']`, async () => { @@ -284,7 +297,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(body[1].action_field).to.eql(['comment']); expect(body[1].action).to.eql('create'); expect(body[1].old_value).to.eql(null); - expect(body[1].new_value).to.eql(JSON.stringify(postCommentUserReq)); + expect(JSON.parse(body[1].new_value)).to.eql(postCommentUserReq); }); it(`on update comment, user action: 'update' should be called with actionFields: ['comments']`, async () => { @@ -317,13 +330,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(3); expect(body[2].action_field).to.eql(['comment']); expect(body[2].action).to.eql('update'); - expect(body[2].old_value).to.eql(JSON.stringify(postCommentUserReq)); - expect(body[2].new_value).to.eql( - JSON.stringify({ - comment: newComment, - type: CommentType.user, - }) - ); + expect(JSON.parse(body[2].old_value)).to.eql(postCommentUserReq); + expect(JSON.parse(body[2].new_value)).to.eql({ + comment: newComment, + type: CommentType.user, + }); }); it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { @@ -359,7 +370,13 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: '2', impact: '2', severity: '2' }, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, }).connector, }) .expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index bb94c31c220d69..01dd6ed5404c2e 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -17,10 +17,21 @@ import { removeServerGeneratedPropertiesFromCase, removeServerGeneratedPropertiesFromComments, } from '../../../common/lib/mock'; +import { + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsByIds, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../../../detection_engine_api_integration/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('case_connector', () => { let createdActionId = ''; @@ -476,6 +487,8 @@ export default ({ getService }: FtrProviderContext): void => { impact: null, severity: null, urgency: null, + category: null, + subcategory: null, }, }, created_by: { @@ -680,47 +693,80 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - // TODO: Remove it when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it('should fail adding a comment of type alert', async () => { - const { body: createdAction } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A case connector', - actionTypeId: '.case', - config: {}, - }) - .expect(200); + describe('adding alerts using a connector', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); - createdActionId = createdAction.id; + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); - const caseRes = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); + it('should add a comment of type alert', async () => { + // TODO: don't do all this stuff + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + const alert = signals.hits.hits[0]; + + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); - const params = { - subAction: 'addComment', - subActionParams: { - caseId: caseRes.body.id, - comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, - }, - }; + createdActionId = createdAction.id; - const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ params }) - .expect(200); + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); - expect(caseConnector.body).to.eql({ - status: 'error', - actionId: createdActionId, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]', - retry: false, + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { alertId: alert._id, index: alert._index, type: CommentType.alert }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body.status).to.eql('ok'); + + const { body } = await supertest + .get(`${CASES_URL}/${caseRes.body.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + const comments = removeServerGeneratedPropertiesFromComments(data.comments ?? []); + expect({ ...data, comments }).to.eql({ + ...postCaseResp(caseRes.body.id), + comments, + totalAlerts: 1, + totalComment: 1, + updated_by: { + email: null, + full_name: null, + username: null, + }, + }); }); }); @@ -789,7 +835,7 @@ export default ({ getService }: FtrProviderContext): void => { }, }; - for (const attribute of ['alertId', 'index']) { + for (const attribute of ['blah', 'bogus']) { const caseConnector = await supertest .post(`/api/actions/action/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') @@ -803,11 +849,10 @@ export default ({ getService }: FtrProviderContext): void => { }, }) .expect(200); - expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert]`, retry: false, }); } diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index f547b781026587..837e6503084a70 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -16,6 +16,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./cases/comments/delete_comment')); loadTestFile(require.resolve('./cases/comments/find_comments')); loadTestFile(require.resolve('./cases/comments/get_comment')); + loadTestFile(require.resolve('./cases/comments/get_all_comments')); loadTestFile(require.resolve('./cases/comments/patch_comment')); loadTestFile(require.resolve('./cases/comments/post_comment')); loadTestFile(require.resolve('./cases/delete_cases')); @@ -33,6 +34,10 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/post_configure')); loadTestFile(require.resolve('./connectors/case')); + loadTestFile(require.resolve('./cases/sub_cases/patch_sub_cases')); + loadTestFile(require.resolve('./cases/sub_cases/delete_sub_cases')); + loadTestFile(require.resolve('./cases/sub_cases/get_sub_case')); + loadTestFile(require.resolve('./cases/sub_cases/find_sub_cases')); // Migrations loadTestFile(require.resolve('./cases/migrations')); diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index 04f08125534562..2f4fa1b30f5649 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -5,6 +5,12 @@ * 2.0. */ +import { + CommentSchemaType, + ContextTypeGeneratedAlertType, + isCommentGeneratedAlert, + transformConnectorComment, +} from '../../../../plugins/case/server/connectors'; import { CasePostRequest, CaseResponse, @@ -15,7 +21,15 @@ import { CommentRequestAlertType, CommentType, CaseStatuses, + CaseType, + CaseClientPostRequest, + SubCaseResponse, + AssociationType, + CollectionWithSubCaseResponse, + SubCasesFindResponse, + CommentRequest, } from '../../../../plugins/case/common/api'; + export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { description: 'This is a brand new case of a bad meanie defacing data', @@ -32,6 +46,22 @@ export const postCaseReq: CasePostRequest = { }, }; +/** + * The fields for creating a collection style case. + */ +export const postCollectionReq: CasePostRequest = { + ...postCaseReq, + type: CaseType.collection, +}; + +/** + * This is needed because the post api does not allow specifying the case type. But the response will include the type. + */ +export const userActionPostResp: CaseClientPostRequest = { + ...postCaseReq, + type: CaseType.individual, +}; + export const postCommentUserReq: CommentRequestUserType = { comment: 'This is a cool comment', type: CommentType.user, @@ -43,6 +73,12 @@ export const postCommentAlertReq: CommentRequestAlertType = { type: CommentType.alert, }; +export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = { + alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }], + index: 'test-index', + type: CommentType.generatedAlert, +}; + export const postCaseResp = ( id: string, req: CasePostRequest = postCaseReq @@ -50,7 +86,9 @@ export const postCaseResp = ( ...req, id, comments: [], + totalAlerts: 0, totalComment: 0, + type: req.type ?? CaseType.individual, closed_by: null, created_by: defaultUser, external_service: null, @@ -58,6 +96,100 @@ export const postCaseResp = ( updated_by: null, }); +interface CommentRequestWithID { + id: string; + comment: CommentSchemaType | CommentRequest; +} + +export const commentsResp = ({ + comments, + associationType, +}: { + comments: CommentRequestWithID[]; + associationType: AssociationType; +}): Array> => { + return comments.map(({ comment, id }) => { + const baseFields = { + id, + created_by: defaultUser, + pushed_at: null, + pushed_by: null, + updated_by: null, + }; + + if (isCommentGeneratedAlert(comment)) { + return { + associationType, + ...transformConnectorComment(comment), + ...baseFields, + }; + } else { + return { + associationType, + ...comment, + ...baseFields, + }; + } + }); +}; + +export const subCaseResp = ({ + id, + totalAlerts, + totalComment, + status = CaseStatuses.open, +}: { + id: string; + status?: CaseStatuses; + totalAlerts: number; + totalComment: number; +}): Partial => ({ + status, + id, + totalAlerts, + totalComment, + closed_by: null, + created_by: defaultUser, + updated_by: defaultUser, +}); + +interface FormattedCollectionResponse { + caseInfo: Partial; + subCase?: Partial; + comments?: Array>; +} + +export const formatCollectionResponse = ( + caseInfo: CollectionWithSubCaseResponse +): FormattedCollectionResponse => { + return { + caseInfo: removeServerGeneratedPropertiesFromCaseCollection(caseInfo), + subCase: removeServerGeneratedPropertiesFromSubCase(caseInfo.subCase), + comments: removeServerGeneratedPropertiesFromComments( + caseInfo.subCase?.comments ?? caseInfo.comments + ), + }; +}; + +export const removeServerGeneratedPropertiesFromSubCase = ( + subCase: Partial | undefined +): Partial | undefined => { + if (!subCase) { + return; + } + // eslint-disable-next-line @typescript-eslint/naming-convention + const { closed_at, created_at, updated_at, version, comments, ...rest } = subCase; + return rest; +}; + +export const removeServerGeneratedPropertiesFromCaseCollection = ( + config: Partial +): Partial => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { closed_at, created_at, updated_at, version, subCase, ...rest } = config; + return rest; +}; + export const removeServerGeneratedPropertiesFromCase = ( config: Partial ): Partial => { @@ -67,21 +199,30 @@ export const removeServerGeneratedPropertiesFromCase = ( }; export const removeServerGeneratedPropertiesFromComments = ( - comments: CommentResponse[] -): Array> => { - return comments.map((comment) => { + comments: CommentResponse[] | undefined +): Array> | undefined => { + return comments?.map((comment) => { // eslint-disable-next-line @typescript-eslint/naming-convention const { created_at, updated_at, version, ...rest } = comment; return rest; }); }; -export const findCasesResp: CasesFindResponse = { +const findCommon = { page: 1, per_page: 20, total: 0, - cases: [], count_open_cases: 0, count_closed_cases: 0, count_in_progress_cases: 0, }; + +export const findCasesResp: CasesFindResponse = { + ...findCommon, + cases: [], +}; + +export const findSubCasesResp: SubCasesFindResponse = { + ...findCommon, + subCases: [], +}; diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 27a49c3f058695..048c5c5d84098c 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -4,14 +4,197 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import expect from '@kbn/expect'; import { Client } from '@elastic/elasticsearch'; +import * as st from 'supertest'; +import supertestAsPromised from 'supertest-as-promised'; +import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../plugins/case/common/constants'; import { CasesConfigureRequest, CasesConfigureResponse, CaseConnector, ConnectorTypes, + CasePostRequest, + CollectionWithSubCaseResponse, + SubCasesFindResponse, + CaseStatuses, + SubCasesResponse, + CasesResponse, } from '../../../../plugins/case/common/api'; +import { postCollectionReq, postCommentGenAlertReq } from './mock'; +import { getSubCasesUrl } from '../../../../plugins/case/common/api/helpers'; +import { ContextTypeGeneratedAlertType } from '../../../../plugins/case/server/connectors'; + +interface SetStatusCasesParams { + id: string; + version: string; + status: CaseStatuses; +} + +/** + * Sets the status of some cases or sub cases. The cases field must be all of one type. + */ +export const setStatus = async ({ + supertest, + cases, + type, +}: { + supertest: st.SuperTest; + cases: SetStatusCasesParams[]; + type: 'case' | 'sub_case'; +}): Promise => { + const url = type === 'case' ? CASES_URL : SUB_CASES_PATCH_DEL_URL; + const patchFields = type === 'case' ? { cases } : { subCases: cases }; + const { body }: { body: CasesResponse | SubCasesResponse } = await supertest + .patch(url) + .set('kbn-xsrf', 'true') + .send(patchFields) + .expect(200); + return body; +}; + +/** + * Variable to easily access the default comment for the createSubCase function. + */ +export const defaultCreateSubComment = postCommentGenAlertReq; + +/** + * Variable to easily access the default comment for the createSubCase function. + */ +export const defaultCreateSubPost = postCollectionReq; + +/** + * Response structure for the createSubCase and createSubCaseComment functions. + */ +export interface CreateSubCaseResp { + newSubCaseInfo: CollectionWithSubCaseResponse; + modifiedSubCases?: SubCasesResponse; +} + +/** + * Creates a sub case using the actions API. If a caseID isn't passed in then it will create + * the collection as well. To create a sub case a comment must be created so it uses a default + * generated alert style comment which can be overridden. + */ +export const createSubCase = async (args: { + supertest: st.SuperTest; + comment?: ContextTypeGeneratedAlertType; + caseID?: string; + caseInfo?: CasePostRequest; + actionID?: string; +}): Promise => { + return createSubCaseComment({ ...args, forceNewSubCase: true }); +}; + +/** + * Add case as a connector + */ +export const createCaseAction = async (supertest: st.SuperTest) => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + return createdAction.id; +}; + +/** + * Remove a connector + */ +export const deleteCaseAction = async ( + supertest: st.SuperTest, + id: string +) => { + await supertest.delete(`/api/actions/action/${id}`).set('kbn-xsrf', 'foo'); +}; + +/** + * Creates a sub case using the actions APIs. This will handle forcing a creation of a new sub case even if one exists + * if the forceNewSubCase parameter is set to true. + */ +export const createSubCaseComment = async ({ + supertest, + caseID, + comment = defaultCreateSubComment, + caseInfo = defaultCreateSubPost, + // if true it will close any open sub cases and force a new sub case to be opened + forceNewSubCase = false, + actionID, +}: { + supertest: st.SuperTest; + comment?: ContextTypeGeneratedAlertType; + caseID?: string; + caseInfo?: CasePostRequest; + forceNewSubCase?: boolean; + actionID?: string; +}): Promise => { + let actionIDToUse: string; + + if (actionID === undefined) { + actionIDToUse = await createCaseAction(supertest); + } else { + actionIDToUse = actionID; + } + + let collectionID: string; + + if (!caseID) { + collectionID = ( + await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(caseInfo).expect(200) + ).body.id; + } else { + collectionID = caseID; + } + + let closedSubCases: SubCasesResponse | undefined; + if (forceNewSubCase) { + const { body: subCasesResp }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(collectionID)}/_find`) + .expect(200); + + const nonClosed = subCasesResp.subCases.filter( + (subCase) => subCase.status !== CaseStatuses.closed + ); + if (nonClosed.length > 0) { + // mark the sub case as closed so a new sub case will be created on the next comment + closedSubCases = ( + await supertest + .patch(SUB_CASES_PATCH_DEL_URL) + .set('kbn-xsrf', 'true') + .send({ + subCases: nonClosed.map((subCase) => ({ + id: subCase.id, + version: subCase.version, + status: CaseStatuses.closed, + })), + }) + .expect(200) + ).body; + } + } + + const caseConnector = await supertest + .post(`/api/actions/action/${actionIDToUse}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'addComment', + subActionParams: { + caseId: collectionID, + comment, + }, + }, + }) + .expect(200); + + expect(caseConnector.body.status).to.eql('ok'); + return { newSubCaseInfo: caseConnector.body.data, modifiedSubCases: closedSubCases }; +}; export const getConfiguration = ({ id = 'none', @@ -104,6 +287,16 @@ export const removeServerGeneratedPropertiesFromConfigure = ( return rest; }; +export const deleteAllCaseItems = async (es: Client) => { + await Promise.all([ + deleteCases(es), + deleteSubCases(es), + deleteCasesUserActions(es), + deleteComments(es), + deleteConfiguration(es), + ]); +}; + export const deleteCasesUserActions = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', @@ -124,6 +317,20 @@ export const deleteCases = async (es: Client): Promise => { }); }; +/** + * Deletes all sub cases in the .kibana index. This uses ES to perform the delete and does + * not go through the case API. + */ +export const deleteSubCases = async (es: Client): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:cases-sub-case', + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + export const deleteComments = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index 9b29548cbe19ef..9e1c290d160590 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -251,6 +251,382 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [ruleResponse.id]); expect(signalsOpen.hits.hits.length).equal(0); }); + + describe('indicator enrichment', () => { + beforeEach(async () => { + await esArchiver.load('filebeat/threat_intel'); + }); + + afterEach(async () => { + await esArchiver.unload('filebeat/threat_intel'); + }); + + it('enriches signals with the single indicator that matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', + threat_query: 'threat.indicator.domain: *', // narrow things down to indicators with a domain + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source.threat); + expect(threats).to.eql([ + { + indicator: [ + { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + matched: { + atomic: '159.89.119.67', + field: 'destination.ip', + type: 'url', + }, + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + ], + }, + { + indicator: [ + { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + matched: { + atomic: '159.89.119.67', + field: 'destination.ip', + type: 'url', + }, + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + ], + }, + ]); + }); + + it('enriches signals with multiple indicators if several matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_query: 'threat.indicator.ip: *', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + expect(signalsOpen.hits.hits.length).equal(1); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source.threat); + expect(threats).to.eql([ + { + indicator: [ + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'ip', + }, + provider: 'other_provider', + type: 'ip', + }, + ], + }, + ]); + }); + + it('adds a single indicator that matched multiple fields', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_query: 'threat.indicator.ip: *', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + expect(signalsOpen.hits.hits.length).equal(1); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source.threat); + + expect(threats).to.eql([ + { + indicator: [ + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'ip', + }, + provider: 'other_provider', + type: 'ip', + }, + // We do not merge matched indicators during enrichment, so in + // certain circumstances a given indicator document could appear + // multiple times in an enriched alert (albeit with different + // threat.indicator.matched data). That's the case with the + // first and third indicators matched, here. + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: 57324, + field: 'source.port', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + ], + }, + ]); + }); + + it('generates multiple signals with multiple matches', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', // narrow our query to a single record that matches two indicators + threat_query: '', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source.threat); + expect(threats).to.eql([ + { + indicator: [ + { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + matched: { + atomic: '159.89.119.67', + field: 'destination.ip', + type: 'url', + }, + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + ], + }, + { + indicator: [ + { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + matched: { + atomic: '159.89.119.67', + field: 'destination.ip', + type: 'url', + }, + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: '45.115.45.3', + field: 'source.ip', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + matched: { + atomic: 57324, + field: 'source.port', + type: 'url', + }, + port: 57324, + provider: 'geenensp', + type: 'url', + }, + ], + }, + ]); + }); + }); }); }); }; diff --git a/x-pack/test/fleet_api_integration/apis/agents_setup.ts b/x-pack/test/fleet_api_integration/apis/agents_setup.ts index c1abdfab566b99..20112afdf76a43 100644 --- a/x-pack/test/fleet_api_integration/apis/agents_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/agents_setup.ts @@ -60,17 +60,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], allow_restricted_indices: false, }, ], diff --git a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts index 2c15cddc81ea1d..31d620cd34931f 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_setup.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_setup.ts @@ -62,15 +62,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*'], + privileges: ['create_doc', 'indices:admin/auto_create'], allow_restricted_indices: false, }, ], @@ -101,17 +94,8 @@ export default function (providerContext: FtrProviderContext) { cluster: ['monitor', 'manage_api_key'], indices: [ { - names: [ - 'logs-*', - 'metrics-*', - 'traces-*', - '.ds-logs-*', - '.ds-metrics-*', - '.ds-traces-*', - '.logs-endpoint.diagnostic.collection-*', - '.ds-.logs-endpoint.diagnostic.collection-*', - ], - privileges: ['write', 'create_index', 'indices:admin/auto_create'], + names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'], + privileges: ['auto_configure', 'create_doc'], allow_restricted_indices: false, }, ], diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts index 4a3dc2b4343920..b93e20ffeed6eb 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts @@ -148,7 +148,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { kibana: [ { feature: { - discover: ['all'], + discover: ['read'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts b/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts index c3f3c563840cae..aed7fc63c7b1b8 100644 --- a/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts +++ b/x-pack/test/functional/apps/api_keys/feature_controls/api_keys_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with manage_security', () => { + describe('global dashboard read with manage_security', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'manage_security'], true); + await security.testUser.setRoles(['global_dashboard_read', 'manage_security'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts b/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts index 9e2976c36b7b4b..421814f550e686 100644 --- a/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts +++ b/x-pack/test/functional/apps/cross_cluster_replication/feature_controls/ccr_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with ccr_user', () => { + describe('global dashboard read with ccr_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'ccr_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'ccr_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts b/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts index a962d22e16551c..f270142b441e28 100644 --- a/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts +++ b/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts @@ -9,10 +9,11 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens']); + const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens', 'header']); const find = getService('find'); const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardVisualizations = getService('dashboardVisualizations'); @@ -69,5 +70,29 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const titles = await PageObjects.dashboard.getPanelTitles(); expect(titles.indexOf(newTitle)).to.not.be(-1); }); + + it('is no longer linked to a dashboard after visiting the visuali1ze listing page', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickLensWidget(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + await PageObjects.lens.notLinkedToOriginatingApp(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // return to origin should not be present in save modal + await testSubjects.click('lnsApp_saveButton'); + const redirectToOriginCheckboxExists = await testSubjects.exists('returnToOriginModeSwitch'); + expect(redirectToOriginCheckboxExists).to.be(false); + }); }); } diff --git a/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts b/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts new file mode 100644 index 00000000000000..15c76c3367a86d --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/dashboard_maps_by_value.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'common', + 'dashboard', + 'maps', + 'timeToVisualize', + 'visualize', + ]); + + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const testSubjects = getService('testSubjects'); + const appsMenu = getService('appsMenu'); + + const LAYER_NAME = 'World Countries'; + let mapCounter = 0; + + async function createAndAddMapByValue() { + log.debug(`createAndAddMapByValue`); + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + if (inViewMode) { + await PageObjects.dashboard.switchToEditMode(); + } + await PageObjects.visualize.clickMapsApp(); + await PageObjects.maps.clickSaveAndReturnButton(); + } + + async function editByValueMap(saveToLibrary = false, saveToDashboard = true) { + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + if (inViewMode) { + await PageObjects.dashboard.switchToEditMode(); + } + + await dashboardPanelActions.clickEdit(); + await PageObjects.maps.clickAddLayer(); + await PageObjects.maps.selectEMSBoundariesSource(); + await PageObjects.maps.selectVectorLayer(LAYER_NAME); + + if (saveToLibrary) { + await testSubjects.click('importFileButton'); + await testSubjects.click('mapSaveButton'); + await PageObjects.timeToVisualize.ensureSaveModalIsOpen; + + await PageObjects.timeToVisualize.saveFromModal(`my map ${mapCounter++}`, { + redirectToOrigin: saveToDashboard, + }); + + if (!saveToDashboard) { + await appsMenu.clickLink('Dashboard'); + } + } else { + await PageObjects.maps.clickSaveAndReturnButton(); + } + + await PageObjects.dashboard.waitForRenderComplete(); + } + + async function createNewDashboard() { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + } + + describe('dashboard maps by value', function () { + before(async () => { + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); + }); + + describe('adding a map by value', () => { + it('can add a map by value', async () => { + await createNewDashboard(); + + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await createAndAddMapByValue(); + const newPanelCount = await PageObjects.dashboard.getPanelCount(); + expect(newPanelCount).to.eql(1); + }); + }); + + describe('editing a map by value', () => { + before(async () => { + await createNewDashboard(); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await createAndAddMapByValue(); + await editByValueMap(); + }); + + it('retains the same number of panels', async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.equal(1); + }); + + it('updates the panel on return', async () => { + const hasLayer = await PageObjects.maps.doesLayerExist(LAYER_NAME); + expect(hasLayer).to.be(true); + }); + }); + + describe('editing a map and adding to map library', () => { + beforeEach(async () => { + await createNewDashboard(); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await createAndAddMapByValue(); + }); + + it('updates the existing panel when adding to dashboard', async () => { + await editByValueMap(true); + + const hasLayer = await PageObjects.maps.doesLayerExist(LAYER_NAME); + + expect(hasLayer).to.be(true); + }); + + it('does not update the panel when only saving to library', async () => { + await editByValueMap(true, false); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/dashboard/index.ts b/x-pack/test/functional/apps/dashboard/index.ts index 5a8278535922e0..1d046c7c182182 100644 --- a/x-pack/test/functional/apps/dashboard/index.ts +++ b/x-pack/test/functional/apps/dashboard/index.ts @@ -18,5 +18,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./sync_colors')); loadTestFile(require.resolve('./_async_dashboard')); loadTestFile(require.resolve('./dashboard_lens_by_value')); + loadTestFile(require.resolve('./dashboard_maps_by_value')); }); } diff --git a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts index bb910e187f9255..72f07ef90d7034 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts +++ b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts @@ -62,6 +62,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const savedSearchPanel = await testSubjects.find('embeddablePanelHeading-EcommerceData'); await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + const actionExists = await testSubjects.exists('embeddablePanelAction-downloadCsvReport'); + if (!actionExists) { + await dashboardPanelActions.clickContextMenuMoreItem(); + } await testSubjects.existOrFail('embeddablePanelAction-downloadCsvReport'); // wait for the full panel to display or else the test runner could click the wrong option! await testSubjects.click('embeddablePanelAction-downloadCsvReport'); await testSubjects.existOrFail('csvDownloadStarted'); // validate toast panel @@ -80,6 +84,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); // panel title is hidden await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + const actionExists = await testSubjects.exists('embeddablePanelAction-downloadCsvReport'); + if (!actionExists) { + await dashboardPanelActions.clickContextMenuMoreItem(); + } await testSubjects.existOrFail('embeddablePanelAction-downloadCsvReport'); await testSubjects.click('embeddablePanelAction-downloadCsvReport'); await testSubjects.existOrFail('csvDownloadStarted'); diff --git a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js index c4cd87a5c33759..57925ad50d155e 100644 --- a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js +++ b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js @@ -29,8 +29,8 @@ export default function ({ getPageObjects, getService }) { it('adds Lens visualization to empty dashboard', async () => { const title = 'Dashboard Test Lens'; - await testSubjects.exists('addVisualizationButton'); - await testSubjects.click('addVisualizationButton'); + await testSubjects.exists('dashboardAddNewPanelButton'); + await testSubjects.click('dashboardAddNewPanelButton'); await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); await PageObjects.lens.createAndAddLensFromDashboard({ title, redirectToOrigin: true }); await PageObjects.dashboard.waitForRenderComplete(); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index ca314cecbea988..d595dc98a9a1a5 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -84,7 +84,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows discover navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Overview', 'Discover']); + expect(navLinks.map((link) => link.text)).to.eql([ + 'Overview', + 'Discover', + 'Stack Management', // because `global_discover_all_role` enables search sessions + ]); }); it('shows save button', async () => { diff --git a/x-pack/test/functional/apps/discover/visualize_field.ts b/x-pack/test/functional/apps/discover/visualize_field.ts index f9312f453e8dd0..d0d7c25c205e51 100644 --- a/x-pack/test/functional/apps/discover/visualize_field.ts +++ b/x-pack/test/functional/apps/discover/visualize_field.ts @@ -52,7 +52,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await retry.try(async () => { const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); expect(dimensions).to.have.length(2); - expect(await dimensions[1].getVisibleText()).to.be('Average of bytes'); + expect(await dimensions[1].getVisibleText()).to.be('Median of bytes'); }); }); diff --git a/x-pack/test/functional/apps/grok_debugger/grok_debugger.js b/x-pack/test/functional/apps/grok_debugger/grok_debugger.js index b2a1c5363fcb61..c21731a2bdc8ab 100644 --- a/x-pack/test/functional/apps/grok_debugger/grok_debugger.js +++ b/x-pack/test/functional/apps/grok_debugger/grok_debugger.js @@ -11,8 +11,8 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['grokDebugger']); - - describe('grok debugger app', function () { + // https://github.com/elastic/kibana/issues/84440 + describe.skip('grok debugger app', function () { this.tags('includeFirefox'); before(async () => { await esArchiver.load('empty_kibana'); diff --git a/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts index e3fe9224905ae5..f71f7e827980cb 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/feature_controls/ilm_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with manage_ilm', () => { + describe('global dashboard read with manage_ilm', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'manage_ilm'], true); + await security.testUser.setRoles(['global_dashboard_read', 'manage_ilm'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts b/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts index bebac46ced78c3..4b453c519fa07f 100644 --- a/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts +++ b/x-pack/test/functional/apps/index_management/feature_controls/index_management_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with index_management_user', () => { + describe('global dashboard read with index_management_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'index_management_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'index_management_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts index 825783eba37d01..8da6871842b152 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts @@ -149,7 +149,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { kibana: [ { feature: { - discover: ['all'], + discover: ['read'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts index c777f68dcdd0bc..6c22ccaa76245c 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/feature_controls/ingest_pipelines_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with ingest_pipelines_user', () => { + describe('global dashboard read with ingest_pipelines_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'ingest_pipelines_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'ingest_pipelines_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/lens/drag_and_drop.ts b/x-pack/test/functional/apps/lens/drag_and_drop.ts index a272b67de1b0ad..0e4d428c260294 100644 --- a/x-pack/test/functional/apps/lens/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/drag_and_drop.ts @@ -149,7 +149,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.dragFieldWithKeyboard('bytes', 4); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ 'Count of records', - 'Average of bytes', + 'Median of bytes', ]); await PageObjects.lens.dragFieldWithKeyboard('@message.raw', 1, true); expect( @@ -169,7 +169,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 0, 1); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([ 'Count of records', - 'Average of bytes', + 'Median of bytes', 'Count of records [1]', ]); diff --git a/x-pack/test/functional/apps/lens/persistent_context.ts b/x-pack/test/functional/apps/lens/persistent_context.ts index 0ed9506149f92b..a3ef8ac33fb9ae 100644 --- a/x-pack/test/functional/apps/lens/persistent_context.ts +++ b/x-pack/test/functional/apps/lens/persistent_context.ts @@ -49,7 +49,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 19, 2025 @ 06:31:44.000' ); await filterBar.toggleFilterEnabled('ip'); - await appsMenu.clickLink('Visualize', { category: 'kibana' }); + await appsMenu.clickLink('Visualize Library', { category: 'kibana' }); await PageObjects.visualize.clickNewVisualization(); await PageObjects.visualize.waitForGroupsSelectPage(); await PageObjects.visualize.clickVisType('lens'); diff --git a/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts b/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts index ce0f3ba673205a..f46cb7100902e7 100644 --- a/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts +++ b/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts @@ -45,9 +45,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with license_management_user', () => { + describe('global dashboard read with license_management_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'license_management_user'], true); + await security.testUser.setRoles( + ['global_dashboard_read', 'license_management_user'], + true + ); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts b/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts index 347679f92a9ec6..587b62547ad8b4 100644 --- a/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts +++ b/x-pack/test/functional/apps/logstash/feature_controls/logstash_security.ts @@ -45,9 +45,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with logstash_read_user', () => { + describe('global dashboard read with logstash_read_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'logstash_read_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'logstash_read_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/management/feature_controls/management_security.ts b/x-pack/test/functional/apps/management/feature_controls/management_security.ts index 57851bc85d8670..7d121e91007499 100644 --- a/x-pack/test/functional/apps/management/feature_controls/management_security.ts +++ b/x-pack/test/functional/apps/management/feature_controls/management_security.ts @@ -28,7 +28,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('no management privileges', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all'], true); + await security.testUser.setRoles(['global_dashboard_read'], true); }); after(async () => { await security.testUser.restoreDefaults(); @@ -68,7 +68,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); expect(sections[1]).to.eql({ sectionId: 'kibana', - sectionLinks: ['indexPatterns', 'objects', 'tags', 'spaces', 'settings'], + sectionLinks: [ + 'indexPatterns', + 'objects', + 'tags', + 'search_sessions', + 'spaces', + 'settings', + ], }); }); }); diff --git a/x-pack/test/functional/apps/maps/layer_errors.js b/x-pack/test/functional/apps/maps/layer_errors.js index c3f231ae125c65..64973461c107b2 100644 --- a/x-pack/test/functional/apps/maps/layer_errors.js +++ b/x-pack/test/functional/apps/maps/layer_errors.js @@ -10,7 +10,6 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects }) { const PageObjects = getPageObjects(['maps', 'header']); - // Failing: See https://github.com/elastic/kibana/issues/69617 describe.skip('layer errors', () => { before(async () => { await PageObjects.maps.loadSavedMap('layer with errors'); @@ -66,14 +65,15 @@ export default function ({ getPageObjects }) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/36011 - describe.skip('EMSFileSource with missing EMS id', () => { + describe('EMSFileSource with missing EMS id', () => { const MISSING_EMS_ID = 'idThatDoesNotExitForEMSFileSource'; const LAYER_NAME = 'EMS_vector_shapes'; it('should diplay error message in layer panel', async () => { const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME); - expect(errorMsg).to.equal(`Unable to find EMS vector shapes for id: ${MISSING_EMS_ID}`); + expect(errorMsg).to.equal( + `Unable to find EMS vector shapes for id: ${MISSING_EMS_ID}. Kibana is unable to access Elastic Maps Service. Contact your system administrator.` + ); }); it('should allow deletion of layer', async () => { @@ -87,10 +87,13 @@ export default function ({ getPageObjects }) { const MISSING_EMS_ID = 'idThatDoesNotExitForEMSTile'; const LAYER_NAME = 'EMS_tiles'; - it('should diplay error message in layer panel', async () => { + // Flaky test on cloud and windows when run against a snapshot build of 7.11. + // https://github.com/elastic/kibana/issues/91043 + + it.skip('should diplay error message in layer panel', async () => { const errorMsg = await PageObjects.maps.getLayerErrorText(LAYER_NAME); expect(errorMsg).to.equal( - `Unable to find EMS tile configuration for id: ${MISSING_EMS_ID}` + `Unable to find EMS tile configuration for id: ${MISSING_EMS_ID}. Kibana is unable to access Elastic Maps Service. Contact your system administrator.` ); }); diff --git a/x-pack/test/functional/apps/maps/sample_data.js b/x-pack/test/functional/apps/maps/sample_data.js index 602b5877bcf156..0c0af2affe50b9 100644 --- a/x-pack/test/functional/apps/maps/sample_data.js +++ b/x-pack/test/functional/apps/maps/sample_data.js @@ -6,29 +6,82 @@ */ import expect from '@kbn/expect'; +import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; export default function ({ getPageObjects, getService, updateBaselines }) { const PageObjects = getPageObjects(['common', 'maps', 'header', 'home', 'timePicker']); const screenshot = getService('screenshots'); + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); - // FLAKY: https://github.com/elastic/kibana/issues/38137 - describe.skip('maps loaded from sample data', () => { - // Sample data is shifted to be relative to current time - // This means that a static timerange will return different documents - // Setting the time range to a window larger than the sample data set - // ensures all documents are coverered by time query so the ES results will always be the same - async function setTimerangeToCoverAllSampleData() { - const past = new Date(); - past.setMonth(past.getMonth() - 6); - const future = new Date(); - future.setMonth(future.getMonth() + 6); - await PageObjects.maps.setAbsoluteRange( - PageObjects.timePicker.formatDateToAbsoluteTimeString(past), - PageObjects.timePicker.formatDateToAbsoluteTimeString(future) - ); - } + // Only update the baseline images from Jenkins session images after comparing them + // These tests might fail locally because of scaling factors and resolution. + + describe('maps loaded from sample data', () => { + before(async () => { + const SAMPLE_DATA_RANGE = `[ + { + "from": "now-30d", + "to": "now+40d", + "display": "sample data range" + }, + { + "from": "now/d", + "to": "now/d", + "display": "Today" + }, + { + "from": "now/w", + "to": "now/w", + "display": "This week" + }, + { + "from": "now-15m", + "to": "now", + "display": "Last 15 minutes" + }, + { + "from": "now-30m", + "to": "now", + "display": "Last 30 minutes" + }, + { + "from": "now-1h", + "to": "now", + "display": "Last 1 hour" + }, + { + "from": "now-24h", + "to": "now", + "display": "Last 24 hours" + }, + { + "from": "now-7d", + "to": "now", + "display": "Last 7 days" + }, + { + "from": "now-30d", + "to": "now", + "display": "Last 30 days" + }, + { + "from": "now-90d", + "to": "now", + "display": "Last 90 days" + }, + { + "from": "now-1y", + "to": "now", + "display": "Last 1 year" + } + ]`; + + await kibanaServer.uiSettings.update({ + [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: SAMPLE_DATA_RANGE, + }); + }); - // Skipped because EMS vectors are not accessible in CI describe('ecommerce', () => { before(async () => { await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { @@ -42,8 +95,11 @@ export default function ({ getPageObjects, getService, updateBaselines }) { await PageObjects.maps.toggleLayerVisibility('France'); await PageObjects.maps.toggleLayerVisibility('United States'); await PageObjects.maps.toggleLayerVisibility('World Countries'); - await setTimerangeToCoverAllSampleData(); + await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); + await PageObjects.maps.closeLegend(); + const mapContainerElement = await testSubjects.find('mapContainer'); + await mapContainerElement.moveMouseTo({ xOffset: 0, yOffset: 0 }); }); after(async () => { @@ -60,7 +116,7 @@ export default function ({ getPageObjects, getService, updateBaselines }) { 'ecommerce_map', updateBaselines ); - expect(percentDifference).to.be.lessThan(0.05); + expect(percentDifference).to.be.lessThan(0.02); }); }); @@ -73,8 +129,11 @@ export default function ({ getPageObjects, getService, updateBaselines }) { await PageObjects.home.addSampleDataSet('flights'); await PageObjects.maps.loadSavedMap('[Flights] Origin and Destination Flight Time'); await PageObjects.maps.toggleLayerVisibility('Road map'); - await setTimerangeToCoverAllSampleData(); + await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); + await PageObjects.maps.closeLegend(); + const mapContainerElement = await testSubjects.find('mapContainer'); + await mapContainerElement.moveMouseTo({ xOffset: 0, yOffset: 0 }); }); after(async () => { @@ -91,11 +150,10 @@ export default function ({ getPageObjects, getService, updateBaselines }) { 'flights_map', updateBaselines ); - expect(percentDifference).to.be.lessThan(0.05); + expect(percentDifference).to.be.lessThan(0.02); }); }); - // Skipped because EMS vectors are not accessible in CI describe('web logs', () => { before(async () => { await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { @@ -106,8 +164,11 @@ export default function ({ getPageObjects, getService, updateBaselines }) { await PageObjects.maps.loadSavedMap('[Logs] Total Requests and Bytes'); await PageObjects.maps.toggleLayerVisibility('Road map'); await PageObjects.maps.toggleLayerVisibility('Total Requests by Country'); - await setTimerangeToCoverAllSampleData(); + await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); + await PageObjects.maps.closeLegend(); + const mapContainerElement = await testSubjects.find('mapContainer'); + await mapContainerElement.moveMouseTo({ xOffset: 0, yOffset: 0 }); }); after(async () => { @@ -124,7 +185,7 @@ export default function ({ getPageObjects, getService, updateBaselines }) { 'web_logs_map', updateBaselines ); - expect(percentDifference).to.be.lessThan(0.06); + expect(percentDifference).to.be.lessThan(0.02); }); }); }); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 009648970c1bb5..59f1775bb21177 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -37,7 +37,7 @@ export default function ({ getService }: FtrProviderContext) { return `user-${this.jobId}`; }, dependentVariable: 'y', - trainingPercent: '20', + trainingPercent: 20, modelMemory: '60mb', createIndexPattern: true, expected: { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index c3febd2021da4f..f41944e3409d76 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -36,7 +36,7 @@ export default function ({ getService }: FtrProviderContext) { return `user-${this.jobId}`; }, dependentVariable: 'stab', - trainingPercent: '20', + trainingPercent: 20, modelMemory: '20mb', createIndexPattern: true, expected: { diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts index ce00ee79e9075a..bc80d8850032b9 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -51,6 +51,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); await ml.dataVisualizerIndexBased.assertActionsPanelExists(); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); // Note the search is not currently passed to the wizard, just the index. await ml.testExecution.logTestStep('displays the actions panel with advanced job card'); diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index 7b4c646f379dee..ad11acb3a6cbbe 100644 --- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -366,6 +366,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index 69ae3961dfd4dd..00cda88e0dc58c 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -359,6 +359,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts b/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts index 8ff9699f9a21ae..0406419098168d 100644 --- a/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts +++ b/x-pack/test/functional/apps/remote_clusters/feature_controls/remote_clusters_security.ts @@ -45,9 +45,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with license_management_user', () => { + describe('global dashboard read with license_management_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'license_management_user'], true); + await security.testUser.setRoles( + ['global_dashboard_read', 'license_management_user'], + true + ); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts index b8d6b88e4ed9a5..04c94e0a3e381d 100644 --- a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts +++ b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts @@ -15,8 +15,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const appsMenu = getService('appsMenu'); const managementMenu = getService('managementMenu'); - // FLAKY: https://github.com/elastic/kibana/issues/90576 - describe.skip('security', () => { + describe('security', () => { before(async () => { await esArchiver.load('empty_kibana'); await PageObjects.security.forceLogout(); @@ -47,9 +46,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with transform_user', () => { + describe('global dashboard read with transform_user', () => { before(async () => { - await security.testUser.setRoles(['global_dashboard_all', 'transform_user'], true); + await security.testUser.setRoles(['global_dashboard_read', 'transform_user'], true); }); after(async () => { await security.testUser.restoreDefaults(); diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts index 3308cf4cc562d9..e30ac06abc7ca0 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts @@ -45,10 +45,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); - describe('global dashboard all with global_upgrade_assistant_role', () => { + describe('global dashboard read with global_upgrade_assistant_role', () => { before(async () => { await security.testUser.setRoles( - ['global_dashboard_all', 'global_upgrade_assistant_role'], + ['global_dashboard_read', 'global_upgrade_assistant_role'], true ); }); @@ -60,7 +60,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(links.map((link) => link.text)).to.contain('Stack Management'); }); - describe('[SkipCloud] global dashboard all with global_upgrade_assistant_role', function () { + describe('[SkipCloud] global dashboard read with global_upgrade_assistant_role', function () { this.tags('skipCloud'); it('should render the "Stack" section with Upgrde Assistant', async function () { await PageObjects.common.navigateToApp('management'); diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index da94eaf19ea3f8..d6644cee211986 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -81,7 +81,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Visualize']); + expect(navLinks).to.eql(['Overview', 'Visualize Library']); }); it(`landing page shows "Create new Visualization" button`, async () => { @@ -212,7 +212,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Visualize']); + expect(navLinks).to.eql(['Overview', 'Visualize Library']); }); it(`landing page shows "Create new Visualization" button`, async () => { @@ -327,7 +327,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Visualize']); + expect(navLinks).to.eql(['Overview', 'Visualize Library']); }); it(`landing page shows "Create new Visualization" button`, async () => { diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts index 5c6ea66f1b0498..469a337177065e 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts @@ -44,7 +44,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { basePath: '/s/custom_space', }); const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.contain('Visualize'); + expect(navLinks).to.contain('Visualize Library'); }); it(`can view existing Visualization`, async () => { @@ -85,7 +85,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { basePath: '/s/custom_space', }); const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).not.to.contain('Visualize'); + expect(navLinks).not.to.contain('Visualize Library'); }); it(`create new visualization shows 404`, async () => { diff --git a/x-pack/test/functional/apps/visualize/preserve_url.ts b/x-pack/test/functional/apps/visualize/preserve_url.ts index b48f82fc0fd2a4..16267a544275c6 100644 --- a/x-pack/test/functional/apps/visualize/preserve_url.ts +++ b/x-pack/test/functional/apps/visualize/preserve_url.ts @@ -27,7 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('visualize'); await PageObjects.visualize.openSavedVisualization('A Pie'); await PageObjects.common.navigateToApp('home'); - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.visChart.waitForVisualization(); const activeTitle = await globalNav.getLastBreadcrumb(); expect(activeTitle).to.be('A Pie'); @@ -43,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('another-space'); // other space - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.visualize.openSavedVisualization('A Pie in another space'); await PageObjects.spaceSelector.openSpacesNav(); @@ -51,7 +51,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('default'); // default space - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.visChart.waitForVisualization(); const activeTitleDefaultSpace = await globalNav.getLastBreadcrumb(); expect(activeTitleDefaultSpace).to.be('A Pie'); @@ -61,7 +61,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.spaceSelector.expectHomePage('another-space'); // other space - await appsMenu.clickLink('Visualize'); + await appsMenu.clickLink('Visualize Library'); await PageObjects.visChart.waitForVisualization(); const activeTitleOtherSpace = await globalNav.getLastBreadcrumb(); expect(activeTitleOtherSpace).to.be('A Pie in another space'); diff --git a/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json new file mode 100644 index 00000000000000..0cbc7f37bd519a --- /dev/null +++ b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json @@ -0,0 +1,276 @@ +{ + "type": "doc", + "value": { + "id": "978783", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "threatintel.abuseurl", + "ingested": "2021-01-26T11:09:06.595350Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978783/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "domain should match the auditbeat hosts' data's source.ip", + "domain": "159.89.119.67", + "first_seen": "2021-01-26T11:09:04.000Z", + "provider": "geenensp", + "type": "url", + "url": { + "full": "http://159.89.119.67:59600/bin.sh", + "scheme": "http" + } + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": false, + "tags": null, + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978784", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "threatintel.abuseurl", + "ingested": "2021-01-26T11:09:06.616763Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978782/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "this should not match the auditbeat hosts data", + "ip": "125.46.136.106", + "first_seen": "2021-01-26T11:06:03.000Z", + "provider": "geenensp", + "type": "ip" + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": true, + "tags": [ + "32-bit", + "elf", + "mips" + ], + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978785", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "threatintel.abuseurl", + "ingested": "2021-01-26T11:09:06.616763Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978782/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "this should match auditbeat/hosts on both port and ip", + "ip": "45.115.45.3", + "port": 57324, + "first_seen": "2021-01-26T11:06:03.000Z", + "provider": "geenensp", + "type": "url" + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": true, + "tags": [ + "32-bit", + "elf", + "mips" + ], + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978787", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "threatintel.abuseurl", + "ingested": "2021-01-26T11:09:06.616763Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978782/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "this should match auditbeat/hosts on ip", + "ip": "45.115.45.3", + "first_seen": "2021-01-26T11:06:03.000Z", + "provider": "other_provider", + "type": "ip" + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": true, + "tags": [ + "32-bit", + "elf", + "mips" + ], + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} diff --git a/x-pack/test/functional/es_archives/filebeat/threat_intel/mappings.json b/x-pack/test/functional/es_archives/filebeat/threat_intel/mappings.json new file mode 100644 index 00000000000000..26d8e29eaecf77 --- /dev/null +++ b/x-pack/test/functional/es_archives/filebeat/threat_intel/mappings.json @@ -0,0 +1,243 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": "filebeat-8.0.0-2021.01.26-000001", + "mappings": { + "_meta": { + "beat": "filebeat", + "version": "7.0.0" + }, + "properties": { + "@timestamp": { + "type": "date" + }, + "@version": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "indicator": { + "properties": { + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "confidence": { + "ignore_above": 1024, + "type": "keyword" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "type": "wildcard" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "first_seen": { + "type": "date" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "last_seen": { + "type": "date" + }, + "marking": { + "properties": { + "tlp": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "matched": { + "properties": { + "atomic": { + "ignore_above": 1024, + "type": "keyword" + }, + "field": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner_stats": { + "type": "long" + }, + "sightings": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "subtechnique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "name": "filebeat-8.0.0", + "rollover_alias": "filebeat-filebeat-8.0.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "0", + "number_of_shards": "1", + "refresh_interval": "5s" + } + } + } +} diff --git a/x-pack/test/functional/screenshots/baseline/ecommerce_map.png b/x-pack/test/functional/screenshots/baseline/ecommerce_map.png index 1450e48012a0b4..8b0e308b7ecb5f 100644 Binary files a/x-pack/test/functional/screenshots/baseline/ecommerce_map.png and b/x-pack/test/functional/screenshots/baseline/ecommerce_map.png differ diff --git a/x-pack/test/functional/screenshots/baseline/flights_map.png b/x-pack/test/functional/screenshots/baseline/flights_map.png index 2a896652e42047..23ece6fb7fa3a0 100644 Binary files a/x-pack/test/functional/screenshots/baseline/flights_map.png and b/x-pack/test/functional/screenshots/baseline/flights_map.png differ diff --git a/x-pack/test/functional/screenshots/baseline/web_logs_map.png b/x-pack/test/functional/screenshots/baseline/web_logs_map.png index 0f2bfed5e0dde9..c3526e73044e5d 100644 Binary files a/x-pack/test/functional/screenshots/baseline/web_logs_map.png and b/x-pack/test/functional/screenshots/baseline/web_logs_map.png differ diff --git a/x-pack/test/functional/services/ml/alerting.ts b/x-pack/test/functional/services/ml/alerting.ts new file mode 100644 index 00000000000000..82f6a86d091992 --- /dev/null +++ b/x-pack/test/functional/services/ml/alerting.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlCommonUI } from './common_ui'; + +export function MachineLearningAlertingProvider( + { getService }: FtrProviderContext, + mlCommonUI: MlCommonUI +) { + const retry = getService('retry'); + const comboBox = getService('comboBox'); + const testSubjects = getService('testSubjects'); + + return { + async selectAnomalyDetectionAlertType() { + await testSubjects.click('xpack.ml.anomaly_detection_alert-SelectOption'); + await retry.tryForTime(5000, async () => { + await testSubjects.existOrFail(`mlAnomalyAlertForm`); + }); + }, + + async selectJobs(jobIds: string[]) { + for (const jobId of jobIds) { + await comboBox.set('mlAnomalyAlertJobSelection > comboBoxInput', jobId); + } + await this.assertJobSelection(jobIds); + }, + + async assertJobSelection(expectedJobIds: string[]) { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + 'mlAnomalyAlertJobSelection > comboBoxInput' + ); + expect(comboBoxSelectedOptions).to.eql( + expectedJobIds, + `Expected job selection to be '${expectedJobIds}' (got '${comboBoxSelectedOptions}')` + ); + }, + + async selectResultType(resultType: string) { + await testSubjects.click(`mlAnomalyAlertResult_${resultType}`); + await this.assertResultTypeSelection(resultType); + }, + + async assertResultTypeSelection(resultType: string) { + await retry.tryForTime(5000, async () => { + await testSubjects.existOrFail(`mlAnomalyAlertResult_${resultType}_selected`); + }); + }, + + async setSeverity(severity: number) { + await mlCommonUI.setSliderValue('mlAnomalyAlertScoreSelection', severity); + }, + + async assertSeverity(expectedValue: number) { + await mlCommonUI.assertSliderValue('mlAnomalyAlertScoreSelection', expectedValue); + }, + + async setTestInterval(interval: string) { + await testSubjects.setValue('mlAnomalyAlertPreviewInterval', interval); + await this.assertTestIntervalValue(interval); + }, + + async assertTestIntervalValue(expectedInterval: string) { + const actualValue = await testSubjects.getAttribute('mlAnomalyAlertPreviewInterval', 'value'); + expect(actualValue).to.eql( + expectedInterval, + `Expected test interval to equal ${expectedInterval}, got ${actualValue}` + ); + }, + + async assertPreviewButtonState(expectedEnabled: boolean) { + const isEnabled = await testSubjects.isEnabled('mlAnomalyAlertPreviewButton'); + expect(isEnabled).to.eql( + expectedEnabled, + `Expected data frame analytics "create" button to be '${ + expectedEnabled ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + }, + + async clickPreviewButton() { + await testSubjects.click('mlAnomalyAlertPreviewButton'); + await this.assertPreviewCalloutVisible(); + }, + + async checkPreview(expectedMessage: string) { + await this.clickPreviewButton(); + const previewMessage = await testSubjects.getVisibleText('mlAnomalyAlertPreviewMessage'); + expect(previewMessage).to.eql(expectedMessage); + }, + + async assertPreviewCalloutVisible() { + await retry.tryForTime(5000, async () => { + await testSubjects.existOrFail(`mlAnomalyAlertPreviewCallout`); + }); + }, + }; +} diff --git a/x-pack/test/functional/services/ml/common_ui.ts b/x-pack/test/functional/services/ml/common_ui.ts index bf24a781fabc3e..727f6493910ff1 100644 --- a/x-pack/test/functional/services/ml/common_ui.ts +++ b/x-pack/test/functional/services/ml/common_ui.ts @@ -163,5 +163,54 @@ export function MachineLearningCommonUIProvider({ getService }: FtrProviderConte // escape popover await browser.pressKeys(browser.keys.ESCAPE); }, + + async setSliderValue(testDataSubj: string, value: number) { + const slider = await testSubjects.find(testDataSubj); + + let currentValue = await slider.getAttribute('value'); + let currentDiff = +currentValue - +value; + + await retry.tryForTime(60 * 1000, async () => { + if (currentDiff === 0) { + return true; + } else { + if (currentDiff > 0) { + if (Math.abs(currentDiff) >= 10) { + slider.type(browser.keys.PAGE_DOWN); + } else { + slider.type(browser.keys.ARROW_LEFT); + } + } else { + if (Math.abs(currentDiff) >= 10) { + slider.type(browser.keys.PAGE_UP); + } else { + slider.type(browser.keys.ARROW_RIGHT); + } + } + await retry.tryForTime(1000, async () => { + const newValue = await slider.getAttribute('value'); + if (newValue !== currentValue) { + currentValue = newValue; + currentDiff = +currentValue - +value; + return true; + } else { + throw new Error(`slider value should have changed, but is still ${currentValue}`); + } + }); + + throw new Error(`slider value should be '${value}' (got '${currentValue}')`); + } + }); + + await this.assertSliderValue(testDataSubj, value); + }, + + async assertSliderValue(testDataSubj: string, expectedValue: number) { + const actualValue = await testSubjects.getAttribute(testDataSubj, 'value'); + expect(actualValue).to.eql( + expectedValue, + `${testDataSubj} slider value should be '${expectedValue}' (got '${actualValue}')` + ); + }, }; } diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index 792241dd9fc163..66c25991274312 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -24,7 +24,6 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); const retry = getService('retry'); - const browser = getService('browser'); return { async assertJobTypeSelectExists() { @@ -273,45 +272,11 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( ); }, - async setTrainingPercent(trainingPercent: string) { - const slider = await testSubjects.find('mlAnalyticsCreateJobWizardTrainingPercentSlider'); - - let currentValue = await slider.getAttribute('value'); - let currentDiff = +currentValue - +trainingPercent; - - await retry.tryForTime(60 * 1000, async () => { - if (currentDiff === 0) { - return true; - } else { - if (currentDiff > 0) { - if (Math.abs(currentDiff) >= 10) { - slider.type(browser.keys.PAGE_DOWN); - } else { - slider.type(browser.keys.ARROW_LEFT); - } - } else { - if (Math.abs(currentDiff) >= 10) { - slider.type(browser.keys.PAGE_UP); - } else { - slider.type(browser.keys.ARROW_RIGHT); - } - } - await retry.tryForTime(1000, async () => { - const newValue = await slider.getAttribute('value'); - if (newValue !== currentValue) { - currentValue = newValue; - currentDiff = +currentValue - +trainingPercent; - return true; - } else { - throw new Error(`slider value should have changed, but is still ${currentValue}`); - } - }); - - throw new Error(`slider value should be '${trainingPercent}' (got '${currentValue}')`); - } - }); - - await this.assertTrainingPercentValue(trainingPercent); + async setTrainingPercent(trainingPercent: number) { + await mlCommonUI.setSliderValue( + 'mlAnalyticsCreateJobWizardTrainingPercentSlider', + trainingPercent + ); }, async assertConfigurationStepActive() { diff --git a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts index d8ec8ed49f011b..53b87042d48da4 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts @@ -153,6 +153,14 @@ export function MachineLearningDataVisualizerIndexBasedProvider({ await testSubjects.clickWhenNotDisabled('mlDataVisualizerCreateAdvancedJobCard'); }, + async assertCreateDataFrameAnalyticsCardExists() { + await testSubjects.existOrFail('mlDataVisualizerCreateDataFrameAnalyticsCard'); + }, + + async assertCreateDataFrameAnalyticsCardNotExists() { + await testSubjects.missingOrFail('mlDataVisualizerCreateDataFrameAnalyticsCard'); + }, + async assertViewInDiscoverCardExists() { await testSubjects.existOrFail('mlDataVisualizerViewInDiscoverCard'); }, diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 202dc1e1d2ce84..91d009316cf9e8 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -45,6 +45,7 @@ import { MachineLearningSingleMetricViewerProvider } from './single_metric_viewe import { MachineLearningTestExecutionProvider } from './test_execution'; import { MachineLearningTestResourcesProvider } from './test_resources'; import { MachineLearningDataVisualizerTableProvider } from './data_visualizer_table'; +import { MachineLearningAlertingProvider } from './alerting'; export function MachineLearningProvider(context: FtrProviderContext) { const commonAPI = MachineLearningCommonAPIProvider(context); @@ -95,10 +96,12 @@ export function MachineLearningProvider(context: FtrProviderContext) { const singleMetricViewer = MachineLearningSingleMetricViewerProvider(context, commonUI); const testExecution = MachineLearningTestExecutionProvider(context); const testResources = MachineLearningTestResourcesProvider(context); + const alerting = MachineLearningAlertingProvider(context, commonUI); return { anomaliesTable, anomalyExplorer, + alerting, api, commonAPI, commonConfig, diff --git a/x-pack/test/functional/services/ml/navigation.ts b/x-pack/test/functional/services/ml/navigation.ts index 3b2d4ef3efa5ae..57ee7e5ad09549 100644 --- a/x-pack/test/functional/services/ml/navigation.ts +++ b/x-pack/test/functional/services/ml/navigation.ts @@ -36,6 +36,12 @@ export function MachineLearningNavigationProvider({ }); }, + async navigateToAlertsAndAction() { + await PageObjects.common.navigateToApp('triggersActions'); + await testSubjects.click('alertsTab'); + await testSubjects.existOrFail('alertsList'); + }, + async assertTabsExist(tabTypeSubject: string, areaSubjects: string[]) { await retry.tryForTime(10000, async () => { const allTabs = await testSubjects.findAll(`~${tabTypeSubject}`, 3); diff --git a/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts index 8a59d6ed3ce2a1..642cc60e904419 100644 --- a/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts +++ b/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -41,8 +41,9 @@ export default function ({ getService }: FtrProviderContext) { }); it('navigates to Discover page', async () => { - await ml.testExecution.logTestStep('should not display create job card'); + await ml.testExecution.logTestStep('should not display create job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); await ml.testExecution.logTestStep('displays the actions panel with view in Discover card'); await ml.dataVisualizerIndexBased.assertActionsPanelExists(); diff --git a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts index b09270b1d0f780..9806c186914a33 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/full_ml_access.ts @@ -134,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts index 14cc4e93b37ab7..632922a353b335 100644 --- a/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional_basic/apps/ml/permissions/read_ml_access.ts @@ -134,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('should not display job cards'); await ml.dataVisualizerIndexBased.assertCreateAdvancedJobCardNotExists(); await ml.dataVisualizerIndexBased.assertRecognizerCardNotExists(ecExpectedModuleId); + await ml.dataVisualizerIndexBased.assertCreateDataFrameAnalyticsCardNotExists(); }); it('should display elements on File Data Visualizer page correctly', async () => { diff --git a/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json b/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json index 9c94f2006b7f87..a0ebde9bff4b79 100644 --- a/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json +++ b/x-pack/test/functional_cors/plugins/kibana_cors_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "kibana_cors_test", + "id": "kibanaCorsTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["test", "cors"], diff --git a/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json b/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json index ea9f55bd21c6ea..919b7f69d28b93 100644 --- a/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json +++ b/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json @@ -1,5 +1,5 @@ { - "id": "iframe_embedded", + "id": "iframeEmbedded", "version": "1.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts new file mode 100644 index 00000000000000..c3859e1044b4fa --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts @@ -0,0 +1,124 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; +import { DATAFEED_STATE } from '../../../../plugins/ml/common/constants/states'; + +function createTestJobAndDatafeed() { + const timestamp = Date.now(); + const jobId = `ec-high_sum_total_sales_${timestamp}`; + + return { + job: { + job_id: jobId, + description: 'test_job_annotation', + groups: ['ecommerce'], + analysis_config: { + bucket_span: '1h', + detectors: [ + { + detector_description: 'High total sales', + function: 'high_sum', + field_name: 'taxful_total_price', + over_field_name: 'customer_full_name.keyword', + detector_index: 0, + }, + ], + influencers: ['customer_full_name.keyword', 'category.keyword'], + }, + data_description: { + time_field: 'order_date', + time_format: 'epoch_ms', + }, + analysis_limits: { + model_memory_limit: '13mb', + categorization_examples_limit: 4, + }, + }, + datafeed: { + datafeed_id: `datafeed-${jobId}`, + job_id: jobId, + query: { + bool: { + must: [ + { + match_all: {}, + }, + ], + filter: [], + must_not: [], + }, + }, + indices: ['ft_ecommerce'], + }, + }; +} + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const pageObjects = getPageObjects(['triggersActionsUI']); + + let testJobId = ''; + + describe('anomaly detection alert', function () { + this.tags('ciGroup13'); + + before(async () => { + await esArchiver.loadIfNeeded('ml/ecommerce'); + await ml.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + + const { job, datafeed } = createTestJobAndDatafeed(); + + testJobId = job.job_id; + + // Set up jobs + await ml.api.createAnomalyDetectionJob(job); + await ml.api.openAnomalyDetectionJob(job.job_id); + await ml.api.createDatafeed(datafeed); + await ml.api.startDatafeed(datafeed.datafeed_id); + await ml.api.waitForDatafeedState(datafeed.datafeed_id, DATAFEED_STATE.STARTED); + await ml.api.assertJobResultsExist(job.job_id); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + describe('overview page alert flyout controls', () => { + it('can create an anomaly detection alert', async () => { + await ml.navigation.navigateToAlertsAndAction(); + await pageObjects.triggersActionsUI.clickCreateAlertButton(); + await ml.alerting.selectAnomalyDetectionAlertType(); + + await ml.testExecution.logTestStep('should have correct default values'); + await ml.alerting.assertSeverity(75); + await ml.alerting.assertPreviewButtonState(false); + + await ml.testExecution.logTestStep('should complete the alert params'); + await ml.alerting.selectJobs([testJobId]); + await ml.alerting.selectResultType('record'); + await ml.alerting.setSeverity(10); + + await ml.testExecution.logTestStep('should preview the alert condition'); + await ml.alerting.assertPreviewButtonState(false); + await ml.alerting.setTestInterval('2y'); + await ml.alerting.assertPreviewButtonState(true); + await ml.alerting.checkPreview('Triggers 2 times in the last 2y'); + + await ml.testExecution.logTestStep('should create an alert'); + await pageObjects.triggersActionsUI.setAlertName('ml-test-alert'); + await pageObjects.triggersActionsUI.setAlertInterval(10, 's'); + await pageObjects.triggersActionsUI.saveAlert(); + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList('ml-test-alert'); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/ml/index.ts b/x-pack/test/functional_with_es_ssl/apps/ml/index.ts new file mode 100644 index 00000000000000..3d0a1c0e4cc75c --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/ml/index.ts @@ -0,0 +1,33 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ loadTestFile, getService }: FtrProviderContext) => { + const ml = getService('ml'); + const esArchiver = getService('esArchiver'); + + describe('ML app', function () { + this.tags(['mlqa', 'skipFirefox']); + + before(async () => { + await ml.securityCommon.createMlRoles(); + await ml.securityCommon.createMlUsers(); + }); + + after(async () => { + await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce'); + await esArchiver.unload('ml/ecommerce'); + await ml.securityCommon.cleanMlUsers(); + await ml.securityCommon.cleanMlRoles(); + await ml.testResources.resetKibanaTimeZone(); + await ml.securityUI.logout(); + }); + + loadTestFile(require.resolve('./alert_flyout')); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index a7259f2410d6b9..5dd1890e240a4d 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -46,6 +46,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [ resolve(__dirname, './apps/triggers_actions_ui'), resolve(__dirname, './apps/uptime'), + resolve(__dirname, './apps/ml'), ], apps: { ...xpackFunctionalConfig.get('apps'), diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json index 784a766e608bc6..11a8fb977cd78b 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json @@ -1,5 +1,5 @@ { - "id": "alerting_fixture", + "id": "alertingFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index 8616cb7c904413..7b5e0c81479f92 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -157,5 +157,34 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) ); await createBtn.click(); }, + async setAlertName(value: string) { + await testSubjects.setValue('alertNameInput', value); + await this.assertAlertName(value); + }, + async assertAlertName(expectedValue: string) { + const actualValue = await testSubjects.getAttribute('alertNameInput', 'value'); + expect(actualValue).to.eql(expectedValue); + }, + async setAlertInterval(value: number, unit?: 's' | 'm' | 'h' | 'd') { + await testSubjects.setValue('intervalInput', value.toString()); + if (unit) { + await testSubjects.selectValue('intervalInputUnit', unit); + } + await this.assertAlertInterval(value, unit); + }, + async assertAlertInterval(expectedValue: number, expectedUnit?: 's' | 'm' | 'h' | 'd') { + const actualValue = await testSubjects.getAttribute('intervalInput', 'value'); + expect(actualValue).to.eql(expectedValue); + if (expectedUnit) { + const actualUnitValue = await testSubjects.getAttribute('intervalInputUnit', 'value'); + expect(actualUnitValue).to.eql(expectedUnit); + } + }, + async saveAlert() { + await testSubjects.click('saveAlertButton'); + const isConfirmationModalVisible = await testSubjects.isDisplayed('confirmAlertSaveModal'); + expect(isConfirmationModalVisible).to.eql(true, 'Expect confirmation modal to be visible'); + await testSubjects.click('confirmModalConfirmButton'); + }, }; } diff --git a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json index 37ec33c168e763..5f4cb3f7f7eb2f 100644 --- a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json @@ -1,5 +1,5 @@ { - "id": "elasticsearch_client_xpack", + "id": "elasticsearchClientXpack", "version": "1.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json b/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json index 4b467ce9750122..4c940ffec14637 100644 --- a/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json @@ -1,5 +1,5 @@ { - "id": "event_log_fixture", + "id": "eventLogFixture", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json index b11b7ada24a578..b81f96362e9f5f 100644 --- a/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/feature_usage_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "feature_usage_test", + "id": "featureUsageTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "feature_usage_test"], diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json index 416ef7fa345919..6a8a2221b48d30 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "sample_task_plugin", + "id": "sampleTaskPlugin", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts index 2878d7d5f8220b..57beb40b164592 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts @@ -218,10 +218,9 @@ export function initRoutes( await ensureIndexIsRefreshed(); const taskManager = await taskManagerStart; return res.ok({ body: await taskManager.get(req.params.taskId) }); - } catch (err) { - return res.ok({ body: err }); + } catch ({ isBoom, output, message }) { + return res.ok({ body: isBoom ? output.payload : { message } }); } - return res.ok({ body: {} }); } ); @@ -251,6 +250,7 @@ export function initRoutes( res: KibanaResponseFactory ): Promise> { try { + await ensureIndexIsRefreshed(); let tasksFound = 0; const taskManager = await taskManagerStart; do { @@ -261,8 +261,8 @@ export function initRoutes( await Promise.all(tasks.map((task) => taskManager.remove(task.id))); } while (tasksFound > 0); return res.ok({ body: 'OK' }); - } catch (err) { - return res.ok({ body: err }); + } catch ({ isBoom, output, message }) { + return res.ok({ body: isBoom ? output.payload : { message } }); } } ); diff --git a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts index 3aee35ed0bff3f..2031551410894a 100644 --- a/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts +++ b/x-pack/test/plugin_api_integration/plugins/sample_task_plugin/server/plugin.ts @@ -105,6 +105,20 @@ export class SampleTaskManagerFixturePlugin // fail after the first failed run maxAttempts: 1, }, + sampleTaskWithSingleConcurrency: { + ...defaultSampleTaskConfig, + title: 'Sample Task With Single Concurrency', + maxConcurrency: 1, + timeout: '60s', + description: 'A sample task that can only have one concurrent instance.', + }, + sampleTaskWithLimitedConcurrency: { + ...defaultSampleTaskConfig, + title: 'Sample Task With Max Concurrency of 2', + maxConcurrency: 2, + timeout: '60s', + description: 'A sample task that can only have two concurrent instance.', + }, sampleRecurringTaskTimingOut: { title: 'Sample Recurring Task that Times Out', description: 'A sample task that times out each run.', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts index 231150a8148354..d99c1dac9a25e9 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/health_route.ts @@ -34,6 +34,7 @@ interface MonitoringStats { timestamp: string; value: { drift: Record; + drift_by_type: Record>; load: Record; execution: { duration: Record>; @@ -43,6 +44,7 @@ interface MonitoringStats { last_successful_poll: string; last_polling_delay: string; duration: Record; + claim_duration: Record; result_frequency_percent_as_number: Record; }; }; @@ -174,7 +176,8 @@ export default function ({ getService }: FtrProviderContext) { const { runtime: { - value: { drift, load, polling, execution }, + // eslint-disable-next-line @typescript-eslint/naming-convention + value: { drift, drift_by_type, load, polling, execution }, }, } = (await getHealth()).stats; @@ -192,11 +195,21 @@ export default function ({ getService }: FtrProviderContext) { expect(typeof polling.duration.p95).to.eql('number'); expect(typeof polling.duration.p99).to.eql('number'); + expect(typeof polling.claim_duration.p50).to.eql('number'); + expect(typeof polling.claim_duration.p90).to.eql('number'); + expect(typeof polling.claim_duration.p95).to.eql('number'); + expect(typeof polling.claim_duration.p99).to.eql('number'); + expect(typeof drift.p50).to.eql('number'); expect(typeof drift.p90).to.eql('number'); expect(typeof drift.p95).to.eql('number'); expect(typeof drift.p99).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p50).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p90).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p95).to.eql('number'); + expect(typeof drift_by_type.sampleTask.p99).to.eql('number'); + expect(typeof load.p50).to.eql('number'); expect(typeof load.p90).to.eql('number'); expect(typeof load.p95).to.eql('number'); diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts index 353be5e872aed7..26333ecabd505d 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management.ts @@ -51,7 +51,7 @@ type SerializedConcreteTaskInstance = Omit< }; export default function ({ getService }: FtrProviderContext) { - const es = getService('legacyEs'); + const es = getService('es'); const log = getService('log'); const retry = getService('retry'); const config = getService('config'); @@ -59,30 +59,46 @@ export default function ({ getService }: FtrProviderContext) { const supertest = supertestAsPromised(url.format(config.get('servers.kibana'))); describe('scheduling and running tasks', () => { - beforeEach( - async () => await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200) - ); + beforeEach(async () => { + // clean up before each test + return await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200); + }); beforeEach(async () => { const exists = await es.indices.exists({ index: testHistoryIndex }); - if (exists) { + if (exists.body) { await es.deleteByQuery({ index: testHistoryIndex, - q: 'type:task', refresh: true, + body: { query: { term: { type: 'task' } } }, }); } else { await es.indices.create({ index: testHistoryIndex, body: { mappings: { - properties: taskManagerIndexMapping, + properties: { + type: { + type: 'keyword', + }, + taskId: { + type: 'keyword', + }, + params: taskManagerIndexMapping.params, + state: taskManagerIndexMapping.state, + runAt: taskManagerIndexMapping.runAt, + }, }, }, }); } }); + after(async () => { + // clean up after last test + return await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200); + }); + function currentTasks(): Promise<{ docs: Array>; }> { @@ -98,7 +114,27 @@ export default function ({ getService }: FtrProviderContext) { return supertest .get(`/api/sample_tasks/task/${task}`) .send({ task }) - .expect(200) + .expect((response) => { + expect(response.status).to.eql(200); + expect(typeof JSON.parse(response.text).id).to.eql(`string`); + }) + .then((response) => response.body); + } + + function currentTaskError( + task: string + ): Promise<{ + statusCode: number; + error: string; + message: string; + }> { + return supertest + .get(`/api/sample_tasks/task/${task}`) + .send({ task }) + .expect(function (response) { + expect(response.status).to.eql(200); + expect(typeof JSON.parse(response.text).message).to.eql(`string`); + }) .then((response) => response.body); } @@ -106,13 +142,21 @@ export default function ({ getService }: FtrProviderContext) { return supertest.get(`/api/ensure_tasks_index_refreshed`).send({}).expect(200); } - function historyDocs(taskId?: string): Promise { + async function historyDocs(taskId?: string): Promise { return es .search({ index: testHistoryIndex, - q: taskId ? `taskId:${taskId}` : 'type:task', + body: { + query: { + term: { type: 'task' }, + }, + }, }) - .then((result: SearchResults) => result.hits.hits); + .then((result) => + ((result.body as unknown) as SearchResults).hits.hits.filter((task) => + taskId ? task._source?.taskId === taskId : true + ) + ); } function scheduleTask( @@ -123,7 +167,10 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ task }) .expect(200) - .then((response: { body: SerializedConcreteTaskInstance }) => response.body); + .then((response: { body: SerializedConcreteTaskInstance }) => { + log.debug(`Task Scheduled: ${response.body.id}`); + return response.body; + }); } function runTaskNow(task: { id: string }) { @@ -252,8 +299,7 @@ export default function ({ getService }: FtrProviderContext) { }); await retry.try(async () => { - const [scheduledTask] = (await currentTasks()).docs; - expect(scheduledTask.id).to.eql(task.id); + const scheduledTask = await currentTask(task.id); expect(scheduledTask.attempts).to.be.greaterThan(0); expect(Date.parse(scheduledTask.runAt)).to.be.greaterThan( Date.parse(task.runAt) + 5 * 60 * 1000 @@ -271,8 +317,7 @@ export default function ({ getService }: FtrProviderContext) { }); await retry.try(async () => { - const [scheduledTask] = (await currentTasks()).docs; - expect(scheduledTask.id).to.eql(task.id); + const scheduledTask = await currentTask(task.id); const retryAt = Date.parse(scheduledTask.retryAt!); expect(isNaN(retryAt)).to.be(false); @@ -296,7 +341,7 @@ export default function ({ getService }: FtrProviderContext) { await retry.try(async () => { expect((await historyDocs(originalTask.id)).length).to.eql(1); - const [task] = (await currentTasks<{ count: number }>()).docs; + const task = await currentTask<{ count: number }>(originalTask.id); expect(task.attempts).to.eql(0); expect(task.state.count).to.eql(count + 1); @@ -467,6 +512,134 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should only run as many instances of a task as its maxConcurrency will allow', async () => { + // should run as there's only one and maxConcurrency on this TaskType is 1 + const firstWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseFirstWaveOfTasks', + }, + }); + + // should run as there's only two and maxConcurrency on this TaskType is 2 + const [firstLimitedConcurrency, secondLimitedConcurrency] = await Promise.all([ + scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseFirstWaveOfTasks', + }, + }), + scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }), + ]); + + await retry.try(async () => { + expect((await historyDocs(firstWithSingleConcurrency.id)).length).to.eql(1); + expect((await historyDocs(firstLimitedConcurrency.id)).length).to.eql(1); + expect((await historyDocs(secondLimitedConcurrency.id)).length).to.eql(1); + }); + + // should not run as there one running and maxConcurrency on this TaskType is 1 + const secondWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }); + + // should not run as there are two running and maxConcurrency on this TaskType is 2 + const thirdWithLimitedConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithLimitedConcurrency', + params: { + waitForEvent: 'releaseSecondWaveOfTasks', + }, + }); + + // schedule a task that should get picked up before the two blocked tasks + const taskWithUnlimitedConcurrency = await scheduleTask({ + taskType: 'sampleTask', + params: {}, + }); + + await retry.try(async () => { + expect((await historyDocs(taskWithUnlimitedConcurrency.id)).length).to.eql(1); + expect((await currentTask(secondWithSingleConcurrency.id)).status).to.eql('idle'); + expect((await currentTask(thirdWithLimitedConcurrency.id)).status).to.eql('idle'); + }); + + // release the running SingleConcurrency task and only one of the LimitedConcurrency tasks + await releaseTasksWaitingForEventToComplete('releaseFirstWaveOfTasks'); + + await retry.try(async () => { + // ensure the completed tasks were deleted + expect((await currentTaskError(firstWithSingleConcurrency.id)).message).to.eql( + `Saved object [task/${firstWithSingleConcurrency.id}] not found` + ); + expect((await currentTaskError(firstLimitedConcurrency.id)).message).to.eql( + `Saved object [task/${firstLimitedConcurrency.id}] not found` + ); + + // ensure blocked tasks is still running + expect((await currentTask(secondLimitedConcurrency.id)).status).to.eql('running'); + + // ensure the blocked tasks begin running + expect((await currentTask(secondWithSingleConcurrency.id)).status).to.eql('running'); + expect((await currentTask(thirdWithLimitedConcurrency.id)).status).to.eql('running'); + }); + + // release blocked task + await releaseTasksWaitingForEventToComplete('releaseSecondWaveOfTasks'); + }); + + it('should return a task run error result when RunNow is called at a time that would cause the task to exceed its maxConcurrency', async () => { + // should run as there's only one and maxConcurrency on this TaskType is 1 + const firstWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + // include a schedule so that the task isn't deleted after completion + schedule: { interval: `30m` }, + params: { + waitForEvent: 'releaseRunningTaskWithSingleConcurrency', + }, + }); + + // should not run as the first is running + const secondWithSingleConcurrency = await scheduleTask({ + taskType: 'sampleTaskWithSingleConcurrency', + params: { + waitForEvent: 'releaseRunningTaskWithSingleConcurrency', + }, + }); + + // run the first tasks once just so that we can be sure it runs in response to our + // runNow callm, rather than the initial execution + await retry.try(async () => { + expect((await historyDocs(firstWithSingleConcurrency.id)).length).to.eql(1); + }); + await releaseTasksWaitingForEventToComplete('releaseRunningTaskWithSingleConcurrency'); + + // wait for second task to stall + await retry.try(async () => { + expect((await historyDocs(secondWithSingleConcurrency.id)).length).to.eql(1); + }); + + // run the first task again using runNow - should fail due to concurrency concerns + const failedRunNowResult = await runTaskNow({ + id: firstWithSingleConcurrency.id, + }); + + expect(failedRunNowResult).to.eql({ + id: firstWithSingleConcurrency.id, + error: `Error: Failed to run task "${firstWithSingleConcurrency.id}" as we would exceed the max concurrency of "Sample Task With Single Concurrency" which is 1. Rescheduled the task to ensure it is picked up as soon as possible.`, + }); + + // release the second task + await releaseTasksWaitingForEventToComplete('releaseRunningTaskWithSingleConcurrency'); + }); + it('should return a task run error result when running a task now fails', async () => { const originalTask = await scheduleTask({ taskType: 'sampleTask', diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json index 1fa480cd53c483..387f392c8db984 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/kibana.json @@ -1,5 +1,5 @@ { - "id": "task_manager_performance", + "id": "taskManagerPerformance", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json b/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json index 499983561e89d3..a203705e13ed67 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json +++ b/x-pack/test/plugin_functional/plugins/resolver_test/kibana.json @@ -1,5 +1,5 @@ { - "id": "resolver_test", + "id": "resolverTest", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "resolverTest"], diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 32cae675dea746..5fac012d5e8b96 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -618,3 +618,37 @@ } } } + +{ + "type": "doc", + "value": { + "id": "sharecapabletype:only_default_space", + "index": ".kibana", + "source": { + "sharecapabletype": { + "title": "A share-capable (isolated) saved-object only in the default space" + }, + "type": "sharecapabletype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharecapabletype:only_space_1", + "index": ".kibana", + "source": { + "sharecapabletype": { + "title": "A share-capable (isolated) saved-object only in space_1" + }, + "type": "sharecapabletype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 561c2ecc56fa26..50c4fb305a6d0c 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -263,6 +263,19 @@ } } }, + "sharecapabletype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, "space": { "properties": { "_reserved": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts index d05a08eeeedd16..e29bbc0db56b6a 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts +++ b/x-pack/test/saved_object_api_integration/common/fixtures/saved_object_test_plugin/server/plugin.ts @@ -52,6 +52,13 @@ export class Plugin { management, mappings, }); + core.savedObjects.registerType({ + name: 'sharecapabletype', + hidden: false, + namespaceType: 'multiple-isolated', + management, + mappings, + }); core.savedObjects.registerType({ name: 'globaltype', hidden: false, diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts index c16d26d834b331..8506611f245608 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -53,6 +53,16 @@ export const SAVED_OBJECT_TEST_CASES: Record = Object.fr id: 'only_space_2', expectedNamespaces: [SPACE_2_ID], }), + MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE: Object.freeze({ + type: 'sharecapabletype', + id: 'only_default_space', + expectedNamespaces: [DEFAULT_SPACE_ID], + }), + MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1: Object.freeze({ + type: 'sharecapabletype', + id: 'only_space_1', + expectedNamespaces: [SPACE_1_ID], + }), NAMESPACE_AGNOSTIC: Object.freeze({ type: 'globaltype', id: 'globaltype-id', diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index 6dfe257f21c0b1..43e92cc21c469c 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -115,7 +115,7 @@ export const createRequest = ({ type, id }: TestCase) => ({ type, id }); const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); const isNamespaceAgnostic = (type: string) => type === 'globaltype'; -const isMultiNamespace = (type: string) => type === 'sharedtype'; +const isMultiNamespace = (type: string) => type === 'sharedtype' || type === 'sharecapabletype'; export const expectResponses = { forbiddenTypes: (action: string) => ( typeOrTypes: string | string[] diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index f46fdcf01367cc..94b75f1fd536d8 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -89,6 +89,27 @@ export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase .flat(), ], }, + ...(spaceId !== SPACE_2_ID && { + // we do not have a multi-namespace isolated object in Space 2 + multiNamespaceIsolatedObject: { + title: 'multi-namespace isolated object', + ...(spaceId === SPACE_1_ID + ? CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1 + : CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE), + }, + }), + multiNamespaceIsolatedType: { + title: 'multi-namespace isolated type', + type: 'sharecapabletype', + successResult: [ + ...(spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [] + : [CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE] + ).flat(), + ], + }, namespaceAgnosticObject: { title: 'namespace-agnostic object', ...CASES.NAMESPACE_AGNOSTIC, diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index cdeb210dddffbc..27905459c29b77 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -107,6 +107,13 @@ export const getTestCases = ( savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'), }, } as FindTestCase, + multiNamespaceIsolatedType: { + title: buildTitle('find multi-namespace isolated type'), + query: `type=sharecapabletype&fields=title${namespacesQueryParam}`, + successResult: { + savedObjects: getExpectedSavedObjects((t) => t.type === 'sharecapabletype'), + }, + } as FindTestCase, namespaceAgnosticType: { title: buildTitle('find namespace-agnostic type'), query: `type=globaltype&fields=title${namespacesQueryParam}`, diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts index 94c417eeeadd5b..80a4a805224bf5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -30,6 +30,7 @@ export type ResolveTestSuite = TestSuite; export interface ResolveTestCase extends TestCase { expectedOutcome?: 'exactMatch' | 'aliasMatch' | 'conflict'; expectedId?: string; + expectedAliasTargetId?: string; } const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; @@ -48,6 +49,7 @@ export const TEST_CASES = Object.freeze({ expectedNamespaces: EACH_SPACE, expectedOutcome: 'aliasMatch' as 'aliasMatch', expectedId: 'alias-match-newid', + expectedAliasTargetId: 'alias-match-newid', }), CONFLICT: Object.freeze({ type: 'resolvetype', @@ -55,6 +57,7 @@ export const TEST_CASES = Object.freeze({ expectedNamespaces: EACH_SPACE, expectedOutcome: 'conflict' as 'conflict', // only in space 1, where the alias exists expectedId: 'conflict', + expectedAliasTargetId: 'conflict-newid', }), DISABLED: Object.freeze({ type: 'resolvetype', @@ -77,10 +80,15 @@ export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest { ...fail409(!overwrite || spaceId !== SPACE_2_ID), ...unresolvableConflict(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index 89a791b06dc5d9..d547b95d34f7ef 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 9cc6cbc967c323..b818a4b6bf33cf 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index eb221fc314ae30..7f5f0b453ff251 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -49,6 +49,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index 13c6b418d30333..6a6fc8a15decfd 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -45,6 +45,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index 788e8e92a9d437..774d7f98f1635d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -19,11 +19,13 @@ const createTestCases = (spaceId: string) => { const exportableObjects = [ cases.singleNamespaceObject, cases.multiNamespaceObject, + cases.multiNamespaceIsolatedObject, cases.namespaceAgnosticObject, ]; const exportableTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, ]; const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 78c38967f6e1d9..6d9c38ecca5962 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -27,6 +27,7 @@ const createTestCases = (currentSpace: string, crossSpaceSearch?: string[]) => { const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, cases.eachType, cases.pageBeyondTotal, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index e493af65257c14..e61d5c10c2dbb3 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index c40d8c3140c6ea..659ee2c2e23635 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -75,6 +75,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict @@ -124,6 +134,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 0ba8c171b3e259..3f213e519e57d0 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -72,6 +72,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict @@ -112,6 +122,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index 5007497df5005a..44296597d52ea7 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -36,6 +36,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index bacade65153b23..b8b57289212da4 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -33,6 +33,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts index b80eb7ed347e0f..18edb7502c65a0 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts index 9b3bc39c64d11c..59da44dcd8ec4a 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts @@ -33,6 +33,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index 3ffb9b2d6705aa..0aae9ebe7c9145 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -32,6 +32,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail409() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index e176c254589148..7d9ec0b152174f 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -31,6 +31,8 @@ const createTestCases = () => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, force: true }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 5cd6ea9242e123..a1580c85a3680b 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -19,11 +19,13 @@ const createTestCases = () => { const exportableObjects = [ cases.singleNamespaceObject, cases.multiNamespaceObject, + cases.multiNamespaceIsolatedObject, cases.namespaceAgnosticObject, ]; const exportableTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, ]; const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index 5a52402fcdf599..eb30024015fbb2 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -24,6 +24,7 @@ const createTestCases = (crossSpaceSearch?: string[]) => { const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, + cases.multiNamespaceIsolatedType, cases.namespaceAgnosticType, cases.eachType, cases.pageBeyondTotal, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts index 5f5417761dbd17..9910900c2f51bc 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index 0cf5cdd98efa8c..b46e3fabff95b3 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -54,6 +54,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...destinationId() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...destinationId() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...destinationId() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_3A_OBJ, ...fail409(!overwrite), ...destinationId() }, // "inexact match" conflict @@ -103,6 +105,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index 7df930d5086644..1d20de4f620fe2 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -46,6 +46,7 @@ const createTestCases = (overwrite: boolean) => { const group2 = [ { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, ...fail409(!overwrite) }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID // all of the cases below represent imports that had an inexact match conflict or an ambiguous conflict @@ -85,6 +86,7 @@ export default function ({ getService }: FtrProviderContext) { 'globaltype', 'isolatedtype', 'sharedtype', + 'sharecapabletype', ]), }), ].flat(), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts index bafc90c710ac31..c0ec36fcf75c4d 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -27,6 +27,8 @@ const createTestCases = () => { CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index aa771d7c48dda0..6bb7828e12f238 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -55,6 +55,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite || spaceId !== SPACE_2_ID), ...unresolvableConflict(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts index 0f78983953bba6..e1d0243377b8e9 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts index 164ecdd2992749..30dc034715ed47 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts @@ -31,6 +31,11 @@ const createTestCases = (spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index ff192530b47cf1..39c97be1b6285e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -44,6 +44,14 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite || spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index 1d38a50a96d191..1a168bac948bef 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -39,6 +39,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts index b34ee15174e996..374bf4f0c2577e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index ffe302883b43ac..b1f30657dd9c0e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -61,6 +61,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index dde99164bd38c1..35f5d3dabde88e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -65,6 +65,16 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), ...destinationId(spaceId !== SPACE_2_ID), }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + ...destinationId(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + ...destinationId(spaceId !== SPACE_1_ID), + }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, { ...CASES.HIDDEN, ...fail400() }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 3940c815aa3532..bf5d635a11d8a7 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -30,6 +30,11 @@ const createTestCases = (spaceId: string) => [ }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, CASES.NAMESPACE_AGNOSTIC, { ...CASES.HIDDEN, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, diff --git a/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json b/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json index faaa0b9165828d..aa7cd499a173af 100644 --- a/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json +++ b/x-pack/test/security_api_integration/fixtures/oidc/oidc_provider/kibana.json @@ -1,5 +1,5 @@ { - "id": "oidc_provider_plugin", + "id": "oidcProviderPlugin", "version": "8.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json index 3cbd37e38bb2da..81ec23fc3d2f3a 100644 --- a/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json +++ b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json @@ -1,5 +1,5 @@ { - "id": "saml_provider_plugin", + "id": "samlProviderPlugin", "version": "8.0.0", "kibanaVersion": "kibana", "server": true, diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts index 994d91ae4a27b9..0798a25a2e9820 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/index.ts @@ -22,5 +22,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { }); loadTestFile(require.resolve('./sessions_management')); + loadTestFile(require.resolve('./sessions_management_permissions')); }); } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts new file mode 100644 index 00000000000000..48f4156afbe82b --- /dev/null +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management_permissions.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const security = getService('security'); + const PageObjects = getPageObjects([ + 'common', + 'header', + 'dashboard', + 'visChart', + 'searchSessionsManagement', + 'security', + ]); + + const appsMenu = getService('appsMenu'); + const managementMenu = getService('managementMenu'); + + describe('Search sessions Management UI permissions', () => { + describe('Sessions management is not available if non of apps enable search sessions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('analyst', 'analyst-password', { + expectSpaceSelector: false, + }); + }); + + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + await PageObjects.security.forceLogout(); + }); + + it('Sessions management is not available if non of apps enable search sessions', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.not.contain('Stack Management'); + }); + }); + + describe('Sessions management is available if one of apps enables search sessions', () => { + before(async () => { + await security.role.create('data_analyst', { + elasticsearch: {}, + kibana: [ + { + feature: { + dashboard: ['read', 'store_search_session'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('analyst', { + password: 'analyst-password', + roles: ['data_analyst'], + full_name: 'test user', + }); + + await PageObjects.security.forceLogout(); + + await PageObjects.security.login('analyst', 'analyst-password', { + expectSpaceSelector: false, + }); + }); + + after(async () => { + await security.role.delete('data_analyst'); + await security.user.delete('analyst'); + await PageObjects.security.forceLogout(); + }); + + it('Sessions management is available if one of apps enables search sessions', async () => { + const links = await appsMenu.readLinks(); + expect(links.map((link) => link.text)).to.contain('Stack Management'); + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(1); + expect(sections[0]).to.eql({ + sectionId: 'kibana', + sectionLinks: ['search_sessions'], + }); + }); + }); + }); +} diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 6209503e756106..1c2e0aeecd2477 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -40,6 +40,8 @@ { "path": "../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../plugins/actions/tsconfig.json" }, { "path": "../plugins/alerts/tsconfig.json" }, + { "path": "../plugins/apm/tsconfig.json" }, + { "path": "../plugins/banners/tsconfig.json" }, { "path": "../plugins/beats_management/tsconfig.json" }, { "path": "../plugins/cloud/tsconfig.json" }, { "path": "../plugins/code/tsconfig.json" }, diff --git a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json index cec1640fbb047b..912cf5d70e16b5 100644 --- a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json +++ b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/kibana.json @@ -1,5 +1,5 @@ { - "id": "foo_plugin", + "id": "fooPlugin", "version": "1.0.0", "kibanaVersion": "kibana", "requiredPlugins": ["features"], diff --git a/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json b/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json index b586de3fa4d793..c41fe744ca9463 100644 --- a/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json +++ b/x-pack/test/usage_collection/plugins/stack_management_usage_test/kibana.json @@ -1,8 +1,8 @@ { - "id": "StackManagementUsageTest", + "id": "stackManagementUsageTest", "version": "1.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "StackManagementUsageTest"], + "configPath": ["xpack", "stackManagementUsageTest"], "requiredPlugins": [], "server": false, "ui": true diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 5589c62010db1e..813811d4a9ce45 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -1,68 +1,23 @@ { "extends": "../tsconfig.base.json", - "include": ["mocks.ts", "typings/**/*", "plugins/**/*", "tasks/**/*"], + "include": [ + "mocks.ts", + "typings/**/*", + "tasks/**/*", + "plugins/case/**/*", + "plugins/lists/**/*", + "plugins/logstash/**/*", + "plugins/monitoring/**/*", + "plugins/security_solution/**/*", + "plugins/xpack_legacy/**/*", + "plugins/drilldowns/url_drilldown/**/*" + ], "exclude": [ - "plugins/actions/**/*", - "plugins/alerts/**/*", + "test/**/*", "plugins/apm/e2e/cypress/**/*", "plugins/apm/ftr_e2e/**/*", "plugins/apm/scripts/**/*", - "plugins/canvas/**/*", - "plugins/console_extensions/**/*", - "plugins/code/**/*", - "plugins/data_enhanced/**/*", - "plugins/discover_enhanced/**/*", - "plugins/dashboard_mode/**/*", - "plugins/dashboard_enhanced/**/*", - "plugins/fleet/**/*", - "plugins/global_search/**/*", - "plugins/global_search_providers/**/*", - "plugins/graph/**/*", - "plugins/features/**/*", - "plugins/file_upload/**/*", - "plugins/embeddable_enhanced/**/*", - "plugins/event_log/**/*", - "plugins/enterprise_search/**/*", - "plugins/infra/**/*", - "plugins/licensing/**/*", - "plugins/lens/**/*", - "plugins/maps/**/*", - "plugins/maps_legacy_licensing/**/*", - "plugins/ml/**/*", - "plugins/observability/**/*", - "plugins/osquery/**/*", - "plugins/reporting/**/*", - "plugins/searchprofiler/**/*", - "plugins/security_solution/cypress/**/*", - "plugins/task_manager/**/*", - "plugins/telemetry_collection_xpack/**/*", - "plugins/transform/**/*", - "plugins/translations/**/*", - "plugins/triggers_actions_ui/**/*", - "plugins/ui_actions_enhanced/**/*", - "plugins/spaces/**/*", - "plugins/security/**/*", - "plugins/stack_alerts/**/*", - "plugins/encrypted_saved_objects/**/*", - "plugins/beats_management/**/*", - "plugins/cloud/**/*", - "plugins/saved_objects_tagging/**/*", - "plugins/global_search_bar/**/*", - "plugins/ingest_pipelines/**/*", - "plugins/license_management/**/*", - "plugins/snapshot_restore/**/*", - "plugins/painless_lab/**/*", - "plugins/watcher/**/*", - "plugins/runtime_fields/**/*", - "plugins/index_management/**/*", - "plugins/grokdebugger/**/*", - "plugins/upgrade_assistant/**/*", - "plugins/rollup/**/*", - "plugins/remote_clusters/**/*", - "plugins/cross_cluster_replication/**/*", - "plugins/index_lifecycle_management/**/*", - "plugins/uptime/**/*", - "test/**/*" + "plugins/security_solution/cypress/**/*" ], "compilerOptions": { // overhead is too significant @@ -106,6 +61,7 @@ { "path": "../src/plugins/usage_collection/tsconfig.json" }, { "path": "./plugins/actions/tsconfig.json" }, { "path": "./plugins/alerts/tsconfig.json" }, + { "path": "./plugins/apm/tsconfig.json" }, { "path": "./plugins/beats_management/tsconfig.json" }, { "path": "./plugins/canvas/tsconfig.json" }, { "path": "./plugins/cloud/tsconfig.json" }, @@ -120,6 +76,7 @@ { "path": "./plugins/event_log/tsconfig.json" }, { "path": "./plugins/features/tsconfig.json" }, { "path": "./plugins/file_upload/tsconfig.json" }, + { "path": "./plugins/fleet/tsconfig.json" }, { "path": "./plugins/global_search_bar/tsconfig.json" }, { "path": "./plugins/global_search_providers/tsconfig.json" }, { "path": "./plugins/global_search/tsconfig.json" }, diff --git a/x-pack/typings/elasticsearch/aggregations.d.ts b/x-pack/typings/elasticsearch/aggregations.d.ts index 795205e82aa6bf..077399c596d543 100644 --- a/x-pack/typings/elasticsearch/aggregations.d.ts +++ b/x-pack/typings/elasticsearch/aggregations.d.ts @@ -190,6 +190,15 @@ export interface AggregationOptionsByType { gap_policy?: 'skip' | 'insert_zeros'; format?: string; }; + rate: { + unit: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; + } & ( + | { + field: string; + mode: 'sum' | 'value_count'; + } + | {} + ); } type AggregationType = keyof AggregationOptionsByType; @@ -409,6 +418,9 @@ interface AggregationResponsePart = TFieldName extends string diff --git a/yarn.lock b/yarn.lock index 319025b3aab77e..8dc2cf35287d75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2146,10 +2146,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@24.4.0": - version "24.4.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.4.0.tgz#217f55540f48a8f59c49250781d99c36110b2544" - integrity sha512-8dxDEs0g1mV4MjPgIArAmdDQDKjH8EitCLh8/Rouv8kkxvdXnL86VkSHpUbZNK9zPAecArwHBSkyCBZNmbqT2A== +"@elastic/charts@24.5.1": + version "24.5.1" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.5.1.tgz#4757721b0323b15412c92d696dd76fdef9b963f8" + integrity sha512-eHJna3xyHREaSfTRb+3/34EmyoINopH6yP9KReakXRb0jW8DD4n9IkbPFwpVN3uXQ6ND2x1ObA0ZzLPSLCPozg== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0" @@ -2161,7 +2161,6 @@ d3-scale "^1.0.7" d3-shape "^1.3.4" newtype-ts "^0.2.4" - path2d-polyfill "^0.4.2" prop-types "^15.7.2" re-reselect "^3.4.0" react-redux "^7.1.0" @@ -3417,6 +3416,10 @@ version "0.0.0" uid "" +"@kbn/apm-utils@link:packages/kbn-apm-utils": + version "0.0.0" + uid "" + "@kbn/babel-code-parser@link:packages/kbn-babel-code-parser": version "0.0.0" uid "" @@ -22841,11 +22844,6 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -path2d-polyfill@^0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-0.4.2.tgz#594d3103838ef6b9dd4a7fd498fe9a88f1f28531" - integrity sha512-JSeAnUfkFjl+Ml/EZL898ivMSbGHrOH63Mirx5EQ1ycJiryHDmj1Q7Are+uEPvenVGCUN9YbolfGfyUewJfJEg== - pathval@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d"