diff --git a/.bazeliskversion b/.bazeliskversion index 6a126f402d53d..4dae2985b58cc 100644 --- a/.bazeliskversion +++ b/.bazeliskversion @@ -1 +1 @@ -1.7.5 +1.10.1 diff --git a/.bazelrc b/.bazelrc index 524542a02a4aa..91c4870ebd126 100644 --- a/.bazelrc +++ b/.bazelrc @@ -10,9 +10,6 @@ build --remote_timeout=30 build --remote_header=x-buildbuddy-api-key=3EYk49W2NefOx2n3yMze build --remote_accept_cached=true -# BuildBuddy -## Metadata settings -build --workspace_status_command="node ./src/dev/bazel_workspace_status.js" # Enable this in case you want to share your build info # build --build_metadata=VISIBILITY=PUBLIC build --build_metadata=TEST_GROUPS=//packages diff --git a/.bazelrc.common b/.bazelrc.common index 3de2bceaad3a6..c401a90507982 100644 --- a/.bazelrc.common +++ b/.bazelrc.common @@ -120,8 +120,8 @@ test --incompatible_strict_action_env # collect coverage information from test targets coverage --instrument_test_targets -# Settings for CI -# Bazel flags for CI are in /src/dev/ci_setup/.bazelrc-ci +# Metadata settings +build --workspace_status_command="node ./src/dev/bazel_workspace_status.js" # Load any settings specific to the current user. # .bazelrc.user should appear in .gitignore so that settings are not shared with team members diff --git a/.bazelversion b/.bazelversion index fcdb2e109f68c..fae6e3d04b2ca 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -4.0.0 +4.2.1 diff --git a/.buildkite/pipelines/es_snapshots/verify.yml b/.buildkite/pipelines/es_snapshots/verify.yml index b9aa0e0e3727a..9cddade0b7482 100755 --- a/.buildkite/pipelines/es_snapshots/verify.yml +++ b/.buildkite/pipelines/es_snapshots/verify.yml @@ -13,6 +13,7 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build + timeout_in_minutes: 10 - wait @@ -22,6 +23,7 @@ steps: queue: c2-16 key: build if: "build.env('KIBANA_BUILD_ID') == null || build.env('KIBANA_BUILD_ID') == ''" + timeout_in_minutes: 60 - command: .buildkite/scripts/steps/functional/xpack_cigroup.sh label: 'Default CI Group' @@ -81,6 +83,7 @@ steps: - command: .buildkite/scripts/steps/es_snapshots/trigger_promote.sh label: Trigger promotion + timeout_in_minutes: 10 depends_on: - default-cigroup - default-cigroup-docker @@ -93,3 +96,4 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build + timeout_in_minutes: 10 diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index 0edba11836fcd..3337cfb5dfcdd 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -3,6 +3,7 @@ env: steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build + timeout_in_minutes: 10 - wait @@ -12,6 +13,7 @@ steps: queue: c2-16 key: build if: "build.env('KIBANA_BUILD_ID') == null || build.env('KIBANA_BUILD_ID') == ''" + timeout_in_minutes: 60 - command: .buildkite/scripts/steps/functional/xpack_cigroup.sh label: 'Default CI Group' @@ -143,21 +145,25 @@ steps: agents: queue: n2-2 key: linting + timeout_in_minutes: 90 - command: .buildkite/scripts/steps/checks.sh label: 'Checks' agents: queue: c2-4 key: checks + timeout_in_minutes: 120 - command: .buildkite/scripts/steps/storybooks/build_and_upload.sh label: 'Build Storybooks' agents: queue: c2-4 key: storybooks + timeout_in_minutes: 60 - wait: ~ continue_on_failure: true - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build + timeout_in_minutes: 10 diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index bceb1796479a2..2aba49bfa6460 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -4,21 +4,27 @@ env: steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build + timeout_in_minutes: 10 - wait - command: .buildkite/scripts/steps/on_merge_build_and_metrics.sh label: Default Build and Metrics + env: + BAZEL_CACHE_MODE: read-write agents: queue: c2-8 + timeout_in_minutes: 60 - command: .buildkite/scripts/steps/on_merge_ts_refs_api_docs.sh label: Build TS Refs and Check Public API Docs agents: queue: c2-4 + timeout_in_minutes: 80 - wait: ~ continue_on_failure: true - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build + timeout_in_minutes: 10 diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index 404bfb273b6f7..1013a841dfd27 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -1,6 +1,7 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build + timeout_in_minutes: 10 - wait @@ -10,6 +11,7 @@ steps: queue: c2-16 key: build if: "build.env('KIBANA_BUILD_ID') == null || build.env('KIBANA_BUILD_ID') == ''" + timeout_in_minutes: 60 - command: .buildkite/scripts/steps/functional/xpack_cigroup.sh label: 'Default CI Group' @@ -17,7 +19,7 @@ steps: agents: queue: ci-group-6 depends_on: build - timeout_in_minutes: 120 + timeout_in_minutes: 150 key: default-cigroup retry: automatic: @@ -141,15 +143,18 @@ steps: agents: queue: n2-2 key: linting + timeout_in_minutes: 90 - command: .buildkite/scripts/steps/checks.sh label: 'Checks' agents: queue: c2-4 key: checks + timeout_in_minutes: 120 - command: .buildkite/scripts/steps/storybooks/build_and_upload.sh label: 'Build Storybooks' agents: queue: c2-4 key: storybooks + timeout_in_minutes: 60 diff --git a/.buildkite/pipelines/update_demo_env.yml b/.buildkite/pipelines/update_demo_env.yml index 1c15b227a2e4a..e2dfdd782fd41 100644 --- a/.buildkite/pipelines/update_demo_env.yml +++ b/.buildkite/pipelines/update_demo_env.yml @@ -1,8 +1,10 @@ steps: - command: .buildkite/scripts/steps/demo_env/es_and_init.sh label: Initialize Environment and Deploy ES + timeout_in_minutes: 10 - command: .buildkite/scripts/steps/demo_env/kibana.sh label: Build and Deploy Kibana agents: queue: c2-8 + timeout_in_minutes: 60 diff --git a/.buildkite/scripts/bootstrap.sh b/.buildkite/scripts/bootstrap.sh index 3c6283a4fe3fd..df38c105d2fd3 100755 --- a/.buildkite/scripts/bootstrap.sh +++ b/.buildkite/scripts/bootstrap.sh @@ -3,6 +3,7 @@ set -euo pipefail source .buildkite/scripts/common/util.sh +source .buildkite/scripts/common/setup_bazel.sh echo "--- yarn install and bootstrap" retry 2 15 yarn kbn bootstrap diff --git a/.buildkite/scripts/common/persist_bazel_cache.sh b/.buildkite/scripts/common/persist_bazel_cache.sh deleted file mode 100755 index 357805c11acec..0000000000000 --- a/.buildkite/scripts/common/persist_bazel_cache.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -source .buildkite/scripts/common/util.sh - -KIBANA_BUILDBUDDY_CI_API_KEY=$(retry 5 5 vault read -field=value secret/kibana-issues/dev/kibana-buildbuddy-ci-api-key) -export KIBANA_BUILDBUDDY_CI_API_KEY - -# overwrites the file checkout .bazelrc file with the one intended for CI env -cp "$KIBANA_DIR/src/dev/ci_setup/.bazelrc-ci" "$KIBANA_DIR/.bazelrc" - -### -### append auth token to buildbuddy into "$KIBANA_DIR/.bazelrc"; -### -echo "# Appended by .buildkite/scripts/persist_bazel_cache.sh" >> "$KIBANA_DIR/.bazelrc" -echo "build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY" >> "$KIBANA_DIR/.bazelrc" diff --git a/.buildkite/scripts/common/setup_bazel.sh b/.buildkite/scripts/common/setup_bazel.sh new file mode 100755 index 0000000000000..bff44c7ba8dd3 --- /dev/null +++ b/.buildkite/scripts/common/setup_bazel.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +source .buildkite/scripts/common/util.sh + +KIBANA_BUILDBUDDY_CI_API_KEY=$(retry 5 5 vault read -field=value secret/kibana-issues/dev/kibana-buildbuddy-ci-api-key) +export KIBANA_BUILDBUDDY_CI_API_KEY + +echo "[bazel] writing .bazelrc" +cat < $KIBANA_DIR/.bazelrc + # Generated by .buildkite/scripts/common/setup_bazel.sh + + import %workspace%/.bazelrc.common + + build --build_metadata=ROLE=CI +EOF + +if [[ "${BAZEL_CACHE_MODE:-none}" == read* ]]; then + echo "[bazel] enabling caching" +cat <> $KIBANA_DIR/.bazelrc + build --bes_results_url=https://app.buildbuddy.io/invocation/ + build --bes_backend=grpcs://cloud.buildbuddy.io + build --remote_cache=grpcs://cloud.buildbuddy.io + build --remote_timeout=3600 + build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY +EOF +fi + +if [[ "${BAZEL_CACHE_MODE:-none}" == "read" ]]; then + echo "[bazel] cache set to read-only" +cat <> $KIBANA_DIR/.bazelrc + build --noremote_upload_local_results +EOF +fi + +if [[ "${BAZEL_CACHE_MODE:-none}" != @(read|read-write|none|) ]]; then + echo "invalid value for BAZEL_CACHE_MODE received ($BAZEL_CACHE_MODE), expected one of [read,read-write,none]" + exit 1 +fi diff --git a/.buildkite/scripts/common/util.sh b/.buildkite/scripts/common/util.sh index a884a147577c9..0e5fb1c40eb6f 100755 --- a/.buildkite/scripts/common/util.sh +++ b/.buildkite/scripts/common/util.sh @@ -18,7 +18,7 @@ verify_no_git_changes() { RED='\033[0;31m' C_RESET='\033[0m' # Reset color - GIT_CHANGES="$(git ls-files --modified)" + GIT_CHANGES="$(git ls-files --modified -- . ':!:.bazelrc')" if [ "$GIT_CHANGES" ]; then echo -e "\n${RED}ERROR: '$1' caused changes to the following files:${C_RESET}\n" echo -e "$GIT_CHANGES\n" diff --git a/.buildkite/scripts/steps/on_merge_build_and_metrics.sh b/.buildkite/scripts/steps/on_merge_build_and_metrics.sh index 315ba08f8719b..1f1e492f87bec 100755 --- a/.buildkite/scripts/steps/on_merge_build_and_metrics.sh +++ b/.buildkite/scripts/steps/on_merge_build_and_metrics.sh @@ -2,9 +2,6 @@ set -euo pipefail -# Write Bazel cache for Linux -.buildkite/scripts/common/persist_bazel_cache.sh - .buildkite/scripts/bootstrap.sh .buildkite/scripts/build_kibana.sh .buildkite/scripts/post_build_kibana.sh diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel index 287b376037abe..d3c44eab2a526 100644 --- a/WORKSPACE.bazel +++ b/WORKSPACE.bazel @@ -28,6 +28,7 @@ check_rules_nodejs_version(minimum_version_string = "3.8.0") node_repositories( node_repositories = { "16.11.1-darwin_amd64": ("node-v16.11.1-darwin-x64.tar.gz", "node-v16.11.1-darwin-x64", "ba54b8ed504bd934d03eb860fefe991419b4209824280d4274f6a911588b5e45"), + "16.11.1-darwin_arm64": ("node-v16.11.1-darwin-arm64.tar.gz", "node-v16.11.1-darwin-arm64", "5e772e478390fab3001b7148a923e4f22fca50170000f18b28475337d3a97248"), "16.11.1-linux_arm64": ("node-v16.11.1-linux-arm64.tar.xz", "node-v16.11.1-linux-arm64", "083fc51f0ea26de9041aaf9821874651a9fd3b20d1cf57071ce6b523a0436f17"), "16.11.1-linux_s390x": ("node-v16.11.1-linux-s390x.tar.xz", "node-v16.11.1-linux-s390x", "855b5c83c2ccb05273d50bb04376335c68d47df57f3187cdebe1f22b972d2825"), "16.11.1-linux_amd64": ("node-v16.11.1-linux-x64.tar.xz", "node-v16.11.1-linux-x64", "493bcc9b660eff983a6de65a0f032eb2717f57207edf74c745bcb86e360310b3"), diff --git a/api_docs/security_solution.json b/api_docs/security_solution.json index b16ae8334f1b0..e159e936e8f42 100644 --- a/api_docs/security_solution.json +++ b/api_docs/security_solution.json @@ -772,17 +772,17 @@ "interfaces": [ { "parentPluginId": "securitySolution", - "id": "def-server.AppRequestContext", + "id": "def-server.SecuritySolutionApiRequestHandlerContext", "type": "Interface", "tags": [], - "label": "AppRequestContext", + "label": "SecuritySolutionApiRequestHandlerContext", "description": [], "path": "x-pack/plugins/security_solution/server/types.ts", "deprecated": false, "children": [ { "parentPluginId": "securitySolution", - "id": "def-server.AppRequestContext.getAppClient", + "id": "def-server.SecuritySolutionApiRequestHandlerContext.getAppClient", "type": "Function", "tags": [], "label": "getAppClient", @@ -804,7 +804,7 @@ }, { "parentPluginId": "securitySolution", - "id": "def-server.AppRequestContext.getSpaceId", + "id": "def-server.SecuritySolutionApiRequestHandlerContext.getSpaceId", "type": "Function", "tags": [], "label": "getSpaceId", @@ -819,7 +819,7 @@ }, { "parentPluginId": "securitySolution", - "id": "def-server.AppRequestContext.getExecutionLogClient", + "id": "def-server.SecuritySolutionApiRequestHandlerContext.getExecutionLogClient", "type": "Function", "tags": [], "label": "getExecutionLogClient", @@ -31438,4 +31438,4 @@ } ] } -} \ No newline at end of file +} diff --git a/docs/dev-tools/console/console.asciidoc b/docs/dev-tools/console/console.asciidoc index 48fe936dd2db5..21334c31011f4 100644 --- a/docs/dev-tools/console/console.asciidoc +++ b/docs/dev-tools/console/console.asciidoc @@ -129,3 +129,12 @@ image::dev-tools/console/images/console-settings.png["Console Settings", width=6 For a list of available keyboard shortcuts, click *Help*. + +[float] +[[console-settings]] +=== Disable Console + +If you don’t want to use *Console*, you can disable it by setting `console.ui.enabled` +to `false` in your `kibana.yml` configuration file. Changing this setting +causes the server to regenerate assets on the next startup, +which might cause a delay before pages start being served. \ No newline at end of file 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 e79bc7a0db026..01e7beae61ce8 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 @@ -211,6 +211,7 @@ readonly links: { clusterPrivileges: string; elasticsearchSettings: string; elasticsearchEnableSecurity: string; + elasticsearchEnableApiKeys: string; indicesPrivileges: string; kibanaTLS: string; kibanaPrivileges: string; @@ -237,6 +238,7 @@ readonly links: { elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; + installElasticAgent: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: 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 d90972d327041..fdf469f443f28 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,5 @@ 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 settings: string;
readonly elasticStackGetStarted: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
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 suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: 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 libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
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: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
readonly troubleshootGaps: string;
};
readonly securitySolution: {
readonly trustedApps: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: 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 spaces: Readonly<{
kibanaLegacyUrlAliases: string;
kibanaDisableLegacyUrlAliasesApi: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
learnMoreBlog: string;
apiKeysLearnMore: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly settings: string;
readonly elasticStackGetStarted: string;
readonly apm: {
readonly kibanaSettings: string;
readonly supportedServiceMaps: string;
readonly customLinks: string;
readonly droppedTransactionSpans: string;
readonly upgrading: string;
readonly metaData: string;
};
readonly canvas: {
readonly guide: string;
};
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 suricataModule: string;
readonly zeekModule: string;
};
readonly auditbeat: {
readonly base: string;
readonly auditdModule: string;
readonly systemModule: 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 libbeat: {
readonly getStarted: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
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: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
readonly sessionLimits: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
readonly runtimeFields: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly rollupJobs: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly privileges: string;
readonly guide: string;
readonly gettingStarted: string;
readonly ml: string;
readonly ruleChangeLog: string;
readonly detectionsReq: string;
readonly networkMap: string;
readonly troubleshootGaps: string;
};
readonly securitySolution: {
readonly trustedApps: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
readonly autocompleteChanges: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Readonly<{
guide: string;
infrastructureThreshold: string;
logsThreshold: string;
metricsThreshold: string;
monitorStatus: string;
monitorUptime: string;
tlsCertificate: string;
uptimeDurationAnomaly: 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 spaces: Readonly<{
kibanaLegacyUrlAliases: string;
kibanaDisableLegacyUrlAliasesApi: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
readonly fleet: Readonly<{
guide: string;
fleetServer: string;
fleetServerAddFleetServer: string;
settings: string;
settingsFleetServerHostSettings: string;
troubleshooting: string;
elasticAgent: string;
datastreams: string;
datastreamsNamingScheme: string;
installElasticAgent: string;
upgradeElasticAgent: string;
upgradeElasticAgent712lower: string;
learnMoreBlog: string;
apiKeysLearnMore: string;
}>;
readonly ecs: {
readonly guide: string;
};
readonly clients: {
readonly guide: string;
readonly goOverview: string;
readonly javaIndex: string;
readonly jsIntro: string;
readonly netGuide: string;
readonly perlGuide: string;
readonly phpGuide: string;
readonly pythonGuide: string;
readonly rubyOverview: string;
readonly rustGuide: string;
};
} | | diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 93d0ee3d2cab6..b2bf5f2bbe308 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -43,6 +43,10 @@ a| <> | Create a security incident in ServiceNow. +a| <> + +| Create an event in ServiceNow. + a| <> | Send a message to a Slack channel or user. diff --git a/docs/management/connectors/action-types/servicenow-itom.asciidoc b/docs/management/connectors/action-types/servicenow-itom.asciidoc new file mode 100644 index 0000000000000..017290dde9b15 --- /dev/null +++ b/docs/management/connectors/action-types/servicenow-itom.asciidoc @@ -0,0 +1,90 @@ +[role="xpack"] +[[servicenow-itom-action-type]] +=== ServiceNow connector and action +++++ +ServiceNow ITOM +++++ + +The ServiceNow ITOM connector uses the https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html[Event API] to create ServiceNow events. + +[float] +[[servicenow-itom-connector-configuration]] +==== Connector configuration + +ServiceNow ITOM connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: ServiceNow instance URL. +Username:: Username for HTTP Basic authentication. +Password:: Password for HTTP Basic authentication. + +The ServiceNow user requires at minimum read, create, and update access to the Event table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render. + +[float] +[[servicenow-itom-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-servicenow-itom-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-servicenow-itom: + name: preconfigured-servicenow-connector-type + actionTypeId: .servicenow-itom + config: + apiUrl: https://example.service-now.com/ + secrets: + username: testuser + password: passwordkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`username`:: A string that corresponds to *Username*. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. + +[float] +[[define-servicenow-itom-ui]] +==== Define connector in Stack Management + +Define ServiceNow ITOM connector properties. + +[role="screenshot"] +image::management/connectors/images/servicenow-itom-connector.png[ServiceNow ITOM connector] + +Test ServiceNow ITOM action parameters. + +[role="screenshot"] +image::management/connectors/images/servicenow-itom-params-test.png[ServiceNow ITOM params test] + +[float] +[[servicenow-itom-action-configuration]] +==== Action configuration + +ServiceNow ITOM actions have the following configuration properties. + +Source:: The name of the event source type. +Node:: The Host that the event was triggered for. +Type:: The type of event. +Resource:: The name of the resource. +Metric name:: Name of the metric. +Source instance (event_class):: Specific instance of the source. +Message key:: All actions sharing this key will be associated with the same ServiceNow alert. Default value: `:`. +Severity:: The severity of the event. +Description:: The details about the event. + +Refer to https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html[ServiceNow documentation] for more information about the properties. + +[float] +[[configuring-servicenow-itom]] +==== Configure ServiceNow ITOM + +ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents. diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc index 2fa49fe552c2e..40fb07897d206 100644 --- a/docs/management/connectors/action-types/servicenow-sir.asciidoc +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -36,7 +36,7 @@ Use the <> to customize connecto name: preconfigured-servicenow-connector-type actionTypeId: .servicenow-sir config: - apiUrl: https://dev94428.service-now.com/ + apiUrl: https://example.service-now.com/ secrets: username: testuser password: passwordkeystorevalue diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index f7c3187f3f024..eae1fce75731d 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -36,7 +36,7 @@ Use the <> to customize connecto name: preconfigured-servicenow-connector-type actionTypeId: .servicenow config: - apiUrl: https://dev94428.service-now.com/ + apiUrl: https://example.service-now.com/ secrets: username: testuser password: passwordkeystorevalue diff --git a/docs/management/connectors/images/servicenow-itom-connector.png b/docs/management/connectors/images/servicenow-itom-connector.png new file mode 100644 index 0000000000000..5b73336d21b47 Binary files /dev/null and b/docs/management/connectors/images/servicenow-itom-connector.png differ diff --git a/docs/management/connectors/images/servicenow-itom-params-test.png b/docs/management/connectors/images/servicenow-itom-params-test.png new file mode 100644 index 0000000000000..8d7b1e075911a Binary files /dev/null and b/docs/management/connectors/images/servicenow-itom-params-test.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 536d05705181d..6968475cf3a4e 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -7,6 +7,7 @@ include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] include::action-types/servicenow-sir.asciidoc[] +include::action-types/servicenow-itom.asciidoc[] include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 11072509da1fc..c291b65c3c35b 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -272,7 +272,7 @@ You can configure the following settings in the `kibana.yml` file. |[[xpack-session-idleTimeout]] `xpack.security.session.idleTimeout` {ess-icon} | Ensures that user sessions will expire after a period of inactivity. This and <> are both -highly recommended. You can also specify this setting for <>. If this is set to `0`, then sessions will never expire due to inactivity. By default, this value is 1 hour. +highly recommended. You can also specify this setting for <>. If this is set to `0`, then sessions will never expire due to inactivity. By default, this value is 8 hours. 2+a| [TIP] diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 4802a4da8182c..af22ad4ad157f 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -20,6 +20,11 @@ configuration using `${MY_ENV_VAR}` syntax. [cols="2*<"] |=== +| `console.ui.enabled:` +Toggling this causes the server to regenerate assets on the next startup, +which may cause a delay before pages start being served. +Set to `false` to disable Console. *Default: `true`* + | `csp.rules:` | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] A https://w3c.github.io/webappsec-csp/[Content Security Policy] template @@ -681,6 +686,10 @@ out through *Advanced Settings*. *Default: `true`* | Set this value to true to allow Vega to use any URL to access external data sources and images. When false, Vega can only get data from {es}. *Default: `false`* +| `xpack.ccr.ui.enabled` +Set this value to false to disable the Cross-Cluster Replication UI. +*Default: `true`* + |[[settings-explore-data-in-context]] `xpack.discoverEnhanced.actions.` `exploreDataInContextMenu.enabled` | Enables the *Explore underlying data* option that allows you to open *Discover* from a dashboard panel and view the panel data. *Default: `false`* @@ -689,6 +698,31 @@ sources and images. When false, Vega can only get data from {es}. *Default: `fal `exploreDataInChart.enabled` | Enables you to view the underlying documents in a data series from a dashboard panel. *Default: `false`* +| `xpack.ilm.ui.enabled` +Set this value to false to disable the Index Lifecycle Policies UI. +*Default: `true`* + +| `xpack.index_management.ui.enabled` +Set this value to false to disable the Index Management UI. +*Default: `true`* + +| `xpack.license_management.ui.enabled` +Set this value to false to disable the License Management UI. +*Default: `true`* + +| `xpack.remote_clusters.ui.enabled` +Set this value to false to disable the Remote Clusters UI. +*Default: `true`* + +| `xpack.rollup.ui.enabled:` +Set this value to false to disable the Rollup Jobs UI. *Default: true* + +| `xpack.snapshot_restore.ui.enabled:` +Set this value to false to disable the Snapshot and Restore UI. *Default: true* + +| `xpack.upgrade_assistant.ui.enabled:` +Set this value to false to disable the Upgrade Assistant UI. *Default: true* + | `i18n.locale` {ess-icon} | Set this value to change the {kib} interface language. Valid locales are: `en`, `zh-CN`, `ja-JP`. *Default: `en`* diff --git a/docs/user/alerting/alerting-setup.asciidoc b/docs/user/alerting/alerting-setup.asciidoc index 3b9868178fa8d..9aae3a27cf373 100644 --- a/docs/user/alerting/alerting-setup.asciidoc +++ b/docs/user/alerting/alerting-setup.asciidoc @@ -17,8 +17,7 @@ If you are using an *on-premises* Elastic Stack deployment: If you are using an *on-premises* Elastic Stack deployment with <>: -* You must enable Transport Layer Security (TLS) for communication <>. {kib} alerting uses <> to secure background rule checks and actions, and API keys require {ref}/configuring-tls.html#tls-http[TLS on the HTTP interface]. A proxy will not suffice. -* If you have enabled TLS and are still unable to access Alerting, ensure that you have not {ref}/security-settings.html#api-key-service-settings[explicitly disabled API keys]. +* If you are unable to access Alerting, ensure that you have not {ref}/security-settings.html#api-key-service-settings[explicitly disabled API keys]. The Alerting framework uses queries that require the `search.allow_expensive_queries` setting to be `true`. See the scripts {ref}/query-dsl-script-query.html#_allow_expensive_queries_4[documentation]. diff --git a/docs/user/security/session-management.asciidoc b/docs/user/security/session-management.asciidoc index b0f27d45bb826..e896c8fe77254 100644 --- a/docs/user/security/session-management.asciidoc +++ b/docs/user/security/session-management.asciidoc @@ -12,7 +12,7 @@ To manage user sessions programmatically, {kib} exposes <[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the idle timeout to expire sessions after 30 minutes of inactivity: +By default, sessions expire after 8 hours of inactivity. To define another value for a sliding session expiration, set the property in the `kibana.yml` configuration file. The idle timeout is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the idle timeout to expire sessions after 30 minutes of inactivity: -- [source,yaml] diff --git a/package.json b/package.json index 47ed5e110b000..f4706b5afe7cd 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.21", "@elastic/ems-client": "7.16.0", - "@elastic/eui": "39.0.0", + "@elastic/eui": "39.1.1", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", @@ -184,11 +184,6 @@ "JSONStream": "1.3.5", "abort-controller": "^3.0.0", "abortcontroller-polyfill": "^1.7.3", - "angular": "^1.8.0", - "angular-aria": "^1.8.0", - "angular-recursion": "^1.0.5", - "angular-route": "^1.8.0", - "angular-sanitize": "^1.8.0", "antlr4ts": "^0.5.0-alpha.3", "archiver": "^5.2.0", "axios": "^0.21.1", @@ -488,7 +483,6 @@ "@testing-library/react": "^11.2.6", "@testing-library/react-hooks": "^5.1.1", "@testing-library/user-event": "^13.1.1", - "@types/angular": "^1.6.56", "@types/apidoc": "^0.22.3", "@types/archiver": "^5.1.0", "@types/babel__core": "^7.1.16", diff --git a/packages/elastic-apm-generator/README.md b/packages/elastic-apm-generator/README.md index e69de29bb2d1d..e43187a8155d3 100644 --- a/packages/elastic-apm-generator/README.md +++ b/packages/elastic-apm-generator/README.md @@ -0,0 +1,93 @@ +# @elastic/apm-generator + +`@elastic/apm-generator` is an experimental tool to generate synthetic APM data. It is intended to be used for development and testing of the Elastic APM app in Kibana. + +At a high-level, the module works by modeling APM events/metricsets with [a fluent API](https://en.wikipedia.org/wiki/Fluent_interface). The models can then be serialized and converted to Elasticsearch documents. In the future we might support APM Server as an output as well. + +## Usage + +This section assumes that you've installed Kibana's dependencies by running `yarn kbn bootstrap` in the repository's root folder. + +This library can currently be used in two ways: + +- Imported as a Node.js module, for instance to be used in Kibana's functional test suite. +- With a command line interface, to index data based on some example scenarios. + +### Using the Node.js module + +#### Concepts + +- `Service`: a logical grouping for a monitored service. A `Service` object contains fields like `service.name`, `service.environment` and `agent.name`. +- `Instance`: a single instance of a monitored service. E.g., the workload for a monitored service might be spread across multiple containers. An `Instance` object contains fields like `service.node.name` and `container.id`. +- `Timerange`: an object that will return an array of timestamps based on an interval and a rate. These timestamps can be used to generate events/metricsets. +- `Transaction`, `Span`, `APMError` and `Metricset`: events/metricsets that occur on an instance. For more background, see the [explanation of the APM data model](https://www.elastic.co/guide/en/apm/get-started/7.15/apm-data-model.html) + + +#### Example + +```ts +import { service, timerange, toElasticsearchOutput } from '@elastic/apm-generator'; + +const instance = service('synth-go', 'production', 'go') + .instance('instance-a'); + +const from = new Date('2021-01-01T12:00:00.000Z').getTime(); +const to = new Date('2021-01-01T12:00:00.000Z').getTime() - 1; + +const traceEvents = timerange(from, to) + .interval('1m') + .rate(10) + .flatMap(timestamp => instance.transaction('GET /api/product/list') + .timestamp(timestamp) + .duration(1000) + .success() + .children( + instance.span('GET apm-*/_search', 'db', 'elasticsearch') + .timestamp(timestamp + 50) + .duration(900) + .destination('elasticsearch') + .success() + ).serialize() + ); + +const metricsets = timerange(from, to) + .interval('30s') + .rate(1) + .flatMap(timestamp => instance.appMetrics({ + 'system.memory.actual.free': 800, + 'system.memory.total': 1000, + 'system.cpu.total.norm.pct': 0.6, + 'system.process.cpu.total.norm.pct': 0.7, + }).timestamp(timestamp) + .serialize() + ); + +const esEvents = toElasticsearchOutput(traceEvents.concat(metricsets)); +``` + +#### Generating metricsets + +`@elastic/apm-generator` can also automatically generate transaction metrics, span destination metrics and transaction breakdown metrics based on the generated trace events. If we expand on the previous example: + +```ts +import { getTransactionMetrics, getSpanDestinationMetrics, getBreakdownMetrics } from '@elastic/apm-generator'; + +const esEvents = toElasticsearchOutput([ + ...traceEvents, + ...getTransactionMetrics(traceEvents), + ...getSpanDestinationMetrics(traceEvents), + ...getBreakdownMetrics(traceEvents) +]); +``` + +### CLI + +Via the CLI, you can upload examples. The supported examples are listed in `src/lib/es.ts`. A `--target` option that specifies the Elasticsearch URL should be defined when running the `example` command. Here's an example: + +`$ node packages/elastic-apm-generator/src/scripts/es.js example simple-trace --target=http://admin:changeme@localhost:9200` + +The following options are supported: +- `to`: the end of the time range, in ISO format. By default, the current time will be used. +- `from`: the start of the time range, in ISO format. By default, `to` minus 15 minutes will be used. +- `apm-server-version`: the version used in the index names bootstrapped by APM Server, e.g. `7.16.0`. __If these indices do not exist, the script will exit with an error. It will not bootstrap the indices itself.__ + diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 9a0863237fab1..1c16d2f1f77da 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -277,7 +277,7 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: terserOptions: { compress: true, keep_classnames: true, - mangle: !['kibanaLegacy', 'monitoring'].includes(bundle.id), + mangle: true, }, }), ], diff --git a/packages/kbn-ui-shared-deps-npm/BUILD.bazel b/packages/kbn-ui-shared-deps-npm/BUILD.bazel index 2ca08a34d761f..bbad873429b2b 100644 --- a/packages/kbn-ui-shared-deps-npm/BUILD.bazel +++ b/packages/kbn-ui-shared-deps-npm/BUILD.bazel @@ -36,7 +36,6 @@ RUNTIME_DEPS = [ "@npm//@elastic/numeral", "@npm//@emotion/react", "@npm//abortcontroller-polyfill", - "@npm//angular", "@npm//babel-loader", "@npm//babel-plugin-transform-react-remove-prop-types", "@npm//core-js", @@ -76,7 +75,6 @@ TYPES_DEPS = [ "@npm//@elastic/numeral", "@npm//@emotion/react", "@npm//abortcontroller-polyfill", - "@npm//angular", "@npm//babel-loader", "@npm//core-js", "@npm//css-loader", diff --git a/packages/kbn-ui-shared-deps-npm/webpack.config.js b/packages/kbn-ui-shared-deps-npm/webpack.config.js index c06ce6704f451..6b572dc3ea208 100644 --- a/packages/kbn-ui-shared-deps-npm/webpack.config.js +++ b/packages/kbn-ui-shared-deps-npm/webpack.config.js @@ -50,7 +50,6 @@ module.exports = (_, argv) => { '@elastic/eui/dist/eui_theme_amsterdam_dark.json', '@elastic/numeral', '@emotion/react', - 'angular', 'classnames', 'fflate', 'history', diff --git a/packages/kbn-ui-shared-deps-src/src/entry.js b/packages/kbn-ui-shared-deps-src/src/entry.js index d19aed4440e7c..c111cbbe60b97 100644 --- a/packages/kbn-ui-shared-deps-src/src/entry.js +++ b/packages/kbn-ui-shared-deps-src/src/entry.js @@ -16,7 +16,6 @@ require('./flot_charts'); // stateful deps export const KbnI18n = require('@kbn/i18n'); export const KbnI18nReact = require('@kbn/i18n/react'); -export const Angular = require('angular'); export const EmotionReact = require('@emotion/react'); export const Moment = require('moment'); export const MomentTimezone = require('moment-timezone/moment-timezone'); diff --git a/packages/kbn-ui-shared-deps-src/src/index.js b/packages/kbn-ui-shared-deps-src/src/index.js index fc5825c6d4777..3e3643d3e2988 100644 --- a/packages/kbn-ui-shared-deps-src/src/index.js +++ b/packages/kbn-ui-shared-deps-src/src/index.js @@ -30,7 +30,6 @@ exports.externals = { /** * stateful deps */ - angular: '__kbnSharedDeps__.Angular', '@kbn/i18n': '__kbnSharedDeps__.KbnI18n', '@kbn/i18n/react': '__kbnSharedDeps__.KbnI18nReact', '@emotion/react': '__kbnSharedDeps__.EmotionReact', diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index a07e12eae8d71..87b05eeafc568 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -357,6 +357,7 @@ export class DocLinksService { clusterPrivileges: `${ELASTICSEARCH_DOCS}security-privileges.html#privileges-list-cluster`, elasticsearchSettings: `${ELASTICSEARCH_DOCS}security-settings.html`, elasticsearchEnableSecurity: `${ELASTICSEARCH_DOCS}configuring-stack-security.html`, + elasticsearchEnableApiKeys: `${ELASTICSEARCH_DOCS}security-settings.html#api-key-service-settings`, indicesPrivileges: `${ELASTICSEARCH_DOCS}security-privileges.html#privileges-list-indices`, kibanaTLS: `${ELASTICSEARCH_DOCS}security-basic-setup.html#encrypt-internode-communication`, kibanaPrivileges: `${KIBANA_DOCS}kibana-privileges.html`, @@ -477,9 +478,10 @@ export class DocLinksService { settings: `${FLEET_DOCS}fleet-settings.html#fleet-server-hosts-setting`, settingsFleetServerHostSettings: `${FLEET_DOCS}fleet-settings.html#fleet-server-hosts-setting`, troubleshooting: `${FLEET_DOCS}fleet-troubleshooting.html`, - elasticAgent: `${FLEET_DOCS}elastic-agent-installation-configuration.html`, + elasticAgent: `${FLEET_DOCS}elastic-agent-installation.html`, datastreams: `${FLEET_DOCS}data-streams.html`, datastreamsNamingScheme: `${FLEET_DOCS}data-streams.html#data-streams-naming-scheme`, + installElasticAgent: `${FLEET_DOCS}install-fleet-managed-elastic-agent.html`, upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`, learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, @@ -714,6 +716,7 @@ export interface DocLinksStart { clusterPrivileges: string; elasticsearchSettings: string; elasticsearchEnableSecurity: string; + elasticsearchEnableApiKeys: string; indicesPrivileges: string; kibanaTLS: string; kibanaPrivileges: string; @@ -740,6 +743,7 @@ export interface DocLinksStart { elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; + installElasticAgent: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index d714f2159d1a2..197714df7f207 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -175,6 +175,7 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiRefreshInterval.legend": "Refresh every", "euiRefreshInterval.start": "Start", "euiRefreshInterval.stop": "Stop", + "euiRelativeTab.dateInputError": "Must be a valid range", "euiRelativeTab.fullDescription": [Function], "euiRelativeTab.numberInputError": "Must be >= 0", "euiRelativeTab.numberInputLabel": "Time span amount", diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 7585ada886c05..f28add25056ee 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -64,7 +64,7 @@ export const getEuiContextMapping = (): EuiTokensObject => { }), 'euiBasicTable.tablePagination': ({ tableCaption }: EuiValues) => i18n.translate('core.euiBasicTable.tablePagination', { - defaultMessage: 'Pagination for preceding table: {tableCaption}', + defaultMessage: 'Pagination for table: {tableCaption}', values: { tableCaption }, description: 'Screen reader text to describe the pagination controls', }), @@ -861,6 +861,9 @@ export const getEuiContextMapping = (): EuiTokensObject => { 'euiRelativeTab.numberInputLabel': i18n.translate('core.euiRelativeTab.numberInputLabel', { defaultMessage: 'Time span amount', }), + 'euiRelativeTab.dateInputError': i18n.translate('core.euiRelativeTab.dateInputError', { + defaultMessage: 'Must be a valid range', + }), 'euiResizableButton.horizontalResizerAriaLabel': i18n.translate( 'core.euiResizableButton.horizontalResizerAriaLabel', { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 4c7f8aab5b767..353e5aa4607e4 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -680,6 +680,7 @@ export interface DocLinksStart { clusterPrivileges: string; elasticsearchSettings: string; elasticsearchEnableSecurity: string; + elasticsearchEnableApiKeys: string; indicesPrivileges: string; kibanaTLS: string; kibanaPrivileges: string; @@ -706,6 +707,7 @@ export interface DocLinksStart { elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; + installElasticAgent: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts index 6ef4f79ef77c9..ce10896747178 100644 --- a/src/core/server/saved_objects/object_types/registration.ts +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -17,6 +17,7 @@ const legacyUrlAliasType: SavedObjectsType = { properties: { sourceId: { type: 'keyword' }, targetType: { type: 'keyword' }, + targetNamespace: { type: 'keyword' }, resolveCounter: { type: 'long' }, disabled: { type: 'boolean' }, // other properties exist, but we aren't querying or aggregating on those, so we don't need to specify them (because we use `dynamic: false` above) 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 82a0dd71700f6..84359147fccbc 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -26,6 +26,7 @@ import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { DocumentMigrator } from '../../migrations/core/document_migrator'; import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock'; +import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import * as esKuery from '@kbn/es-query'; import { errors as EsErrors } from '@elastic/elasticsearch'; @@ -2714,7 +2715,11 @@ describe('SavedObjectsRepository', () => { const allTypes = registry.getAllTypes().map((type) => type.name); expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { namespaces: [namespace], - type: allTypes.filter((type) => !registry.isNamespaceAgnostic(type)), + type: [ + ...allTypes.filter((type) => !registry.isNamespaceAgnostic(type)), + LEGACY_URL_ALIAS_TYPE, + ], + kueryNode: expect.anything(), }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index e522d770b3f58..c081c59911405 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -8,6 +8,7 @@ import { omit, isObject } from 'lodash'; import type { estypes } from '@elastic/elasticsearch'; +import * as esKuery from '@kbn/es-query'; import type { ElasticsearchClient } from '../../../elasticsearch/'; import { isSupportedEsServer, isNotFoundFromUnsupportedServer } from '../../../elasticsearch'; import type { Logger } from '../../../logging'; @@ -55,6 +56,7 @@ import { SavedObjectsBulkResolveObject, SavedObjectsBulkResolveResponse, } from '../saved_objects_client'; +import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { SavedObject, SavedObjectsBaseOptions, @@ -780,7 +782,16 @@ export class SavedObjectsRepository { } const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); - const typesToUpdate = allTypes.filter((type) => !this._registry.isNamespaceAgnostic(type)); + const typesToUpdate = [ + ...allTypes.filter((type) => !this._registry.isNamespaceAgnostic(type)), + LEGACY_URL_ALIAS_TYPE, + ]; + + // Construct kueryNode to filter legacy URL aliases (these space-agnostic objects do not use root-level "namespace/s" fields) + const { buildNode } = esKuery.nodeTypes.function; + const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.targetNamespace`, namespace); + const match2 = buildNode('not', buildNode('is', 'type', LEGACY_URL_ALIAS_TYPE)); + const kueryNode = buildNode('or', [match1, match2]); const { body, statusCode, headers } = await this.client.updateByQuery( { @@ -803,8 +814,9 @@ export class SavedObjectsRepository { }, conflicts: 'proceed', ...getSearchDsl(this._mappings, this._registry, { - namespaces: namespace ? [namespace] : undefined, + namespaces: [namespace], type: typesToUpdate, + kueryNode, }), }, }, diff --git a/src/dev/build/lib/config.test.ts b/src/dev/build/lib/config.test.ts index 8bc5eb70c9437..b2afe3337230d 100644 --- a/src/dev/build/lib/config.test.ts +++ b/src/dev/build/lib/config.test.ts @@ -107,6 +107,7 @@ describe('#getTargetPlatforms()', () => { .sort() ).toMatchInlineSnapshot(` Array [ + "darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", @@ -132,7 +133,7 @@ describe('#getNodePlatforms()', () => { .getTargetPlatforms() .map((p) => p.getNodeArch()) .sort() - ).toEqual(['darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); + ).toEqual(['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); }); it('returns this platform and linux, when targetAllPlatforms = false', async () => { diff --git a/src/dev/build/lib/platform.test.ts b/src/dev/build/lib/platform.test.ts index 79b07cac09cfc..193579d1a35c1 100644 --- a/src/dev/build/lib/platform.test.ts +++ b/src/dev/build/lib/platform.test.ts @@ -31,6 +31,7 @@ describe('isWindows()', () => { expect(new Platform('win32', 'x64', 'foo').isWindows()).toBe(true); expect(new Platform('linux', 'x64', 'foo').isWindows()).toBe(false); expect(new Platform('darwin', 'x64', 'foo').isWindows()).toBe(false); + expect(new Platform('darwin', 'arm64', 'foo').isWindows()).toBe(false); }); }); @@ -39,6 +40,7 @@ describe('isLinux()', () => { expect(new Platform('win32', 'x64', 'foo').isLinux()).toBe(false); expect(new Platform('linux', 'x64', 'foo').isLinux()).toBe(true); expect(new Platform('darwin', 'x64', 'foo').isLinux()).toBe(false); + expect(new Platform('darwin', 'arm64', 'foo').isLinux()).toBe(false); }); }); @@ -47,5 +49,6 @@ describe('isMac()', () => { expect(new Platform('win32', 'x64', 'foo').isMac()).toBe(false); expect(new Platform('linux', 'x64', 'foo').isMac()).toBe(false); expect(new Platform('darwin', 'x64', 'foo').isMac()).toBe(true); + expect(new Platform('darwin', 'arm64', 'foo').isMac()).toBe(true); }); }); diff --git a/src/dev/build/lib/platform.ts b/src/dev/build/lib/platform.ts index 2df7801ffc10e..4c4ec271318d6 100644 --- a/src/dev/build/lib/platform.ts +++ b/src/dev/build/lib/platform.ts @@ -49,5 +49,6 @@ export const ALL_PLATFORMS = [ new Platform('linux', 'x64', 'linux-x86_64'), new Platform('linux', 'arm64', 'linux-aarch64'), new Platform('darwin', 'x64', 'darwin-x86_64'), + new Platform('darwin', 'arm64', 'darwin-aarch64'), new Platform('win32', 'x64', 'windows-x86_64'), ]; diff --git a/src/dev/build/tasks/install_chromium.js b/src/dev/build/tasks/install_chromium.js index 95e0df8984f9d..ad60019ea81a4 100644 --- a/src/dev/build/tasks/install_chromium.js +++ b/src/dev/build/tasks/install_chromium.js @@ -16,7 +16,11 @@ export const InstallChromium = { async run(config, log, build) { for (const platform of config.getNodePlatforms()) { - log.info(`Installing Chromium for ${platform.getName()}-${platform.getArchitecture()}`); + const target = `${platform.getName()}-${platform.getArchitecture()}`; + log.info(`Installing Chromium for ${target}`); + + // revert after https://github.com/elastic/kibana/issues/109949 + if (target === 'darwin-arm64') continue; const { binaryPath$ } = installBrowser( log, diff --git a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts index ca43f78a40cfd..31374d2050971 100644 --- a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts @@ -98,6 +98,15 @@ it('downloads node builds for each platform', async () => { "url": "darwin:url", }, ], + Array [ + Object { + "destination": "darwin:downloadPath", + "log": , + "retries": 3, + "sha256": "darwin:sha256", + "url": "darwin:url", + }, + ], Array [ Object { "destination": "win32:downloadPath", diff --git a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts index 37a017ed083d0..9f869b99c18ae 100644 --- a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts @@ -105,6 +105,13 @@ it('runs expected fs operations', async () => { "strip": 1, }, ], + Array [ + /.node_binaries//node-v-darwin-arm64.tar.gz, + /.node_binaries//darwin-arm64, + Object { + "strip": 1, + }, + ], ], } `); diff --git a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts index b097deb46f61c..c636db145694c 100644 --- a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts @@ -98,6 +98,7 @@ it('checks shasums for each downloaded node build', async () => { Object { "type": "return", "value": Object { + "darwin:darwin-arm64:downloadName": "valid shasum", "darwin:darwin-x64:downloadName": "valid shasum", "linux:linux-arm64:downloadName": "valid shasum", "linux:linux-x64:downloadName": "valid shasum", @@ -134,6 +135,14 @@ it('checks shasums for each downloaded node build', async () => { "name": "darwin", }, ], + Array [ + , + Platform { + "architecture": "arm64", + "buildName": "darwin-aarch64", + "name": "darwin", + }, + ], Array [ , Platform { @@ -165,6 +174,13 @@ it('checks shasums for each downloaded node build', async () => { "downloadPath": "darwin:darwin-x64:downloadPath", }, }, + Object { + "type": "return", + "value": Object { + "downloadName": "darwin:darwin-arm64:downloadName", + "downloadPath": "darwin:darwin-arm64:downloadPath", + }, + }, Object { "type": "return", "value": Object { @@ -190,6 +206,10 @@ it('checks shasums for each downloaded node build', async () => { "darwin:darwin-x64:downloadPath", "sha256", ], + Array [ + "darwin:darwin-arm64:downloadPath", + "sha256", + ], Array [ "win32:win32-x64:downloadPath", "sha256", @@ -212,6 +232,10 @@ it('checks shasums for each downloaded node build', async () => { "type": "return", "value": "valid shasum", }, + Object { + "type": "return", + "value": "valid shasum", + }, ], } `); diff --git a/src/dev/build/tasks/patch_native_modules_task.ts b/src/dev/build/tasks/patch_native_modules_task.ts index bb2b9cc96b677..37cb729053785 100644 --- a/src/dev/build/tasks/patch_native_modules_task.ts +++ b/src/dev/build/tasks/patch_native_modules_task.ts @@ -58,6 +58,10 @@ const packages: Package[] = [ url: 'https://storage.googleapis.com/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.16.0/linux-arm64-93.gz', sha256: '7a786e0b75985e5aafdefa9af55cad8e85e69a3326f16d8c63d21d6b5b3bff1b', }, + 'darwin-arm64': { + url: 'https://storage.googleapis.com/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.16.0/darwin-arm64-93.gz', + sha256: '28b540cdddf13578f1bd28a03e29ffdc26a7f00ec859c369987b8d51ec6357c8', + }, 'win32-x64': { url: 'https://github.com/uhop/node-re2/releases/download/1.16.0/win32-x64-93.gz', sha256: '37245ceb59a086b5e7e9de8746a3cdf148c383be9ae2580f92baea90d0d39947', diff --git a/src/dev/ci_setup/.bazelrc-ci b/src/dev/ci_setup/.bazelrc-ci index 9aee657f37bcb..a0a0c3de73405 100644 --- a/src/dev/ci_setup/.bazelrc-ci +++ b/src/dev/ci_setup/.bazelrc-ci @@ -1,15 +1,5 @@ -# Used in the on-merge job to persist the Bazel cache to BuildBuddy -# from: .buildkite/scripts/common/persist_bazel_cache.sh +# Generated by .buildkite/scripts/common/setup_bazel.sh import %workspace%/.bazelrc.common -# BuildBuddy settings -build --bes_results_url=https://app.buildbuddy.io/invocation/ -build --bes_backend=grpcs://cloud.buildbuddy.io -build --remote_cache=grpcs://cloud.buildbuddy.io -build --remote_timeout=3600 -# --remote_header=x-buildbuddy-api-key= # appended in CI script - -# Metadata settings build --build_metadata=ROLE=CI -build --workspace_status_command="node ./src/dev/bazel_workspace_status.js" diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_1.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_1.jsx index a52047f00feb0..8dc47a53da421 100644 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_1.jsx +++ b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_1.jsx @@ -1,11 +1,8 @@ /* eslint-disable */ -// Angular service -i18n('plugin_1.id_1', { defaultMessage: 'Message 1' }); - // @kbn/i18n -i18n.translate('plugin_1.id_2', { - defaultMessage: 'Message 2', +i18n.translate('plugin_1.id_1', { + defaultMessage: 'Message 1', description: 'Message description', }); @@ -15,10 +12,10 @@ class Component extends PureComponent { return (
- {intl.formatMessage({ id: 'plugin_1.id_4', defaultMessage: 'Message 4' })} + {intl.formatMessage({ id: 'plugin_1.id_3', defaultMessage: 'Message 3' })}
); } diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html deleted file mode 100644 index f9c8a8383d647..0000000000000 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
-
-
diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html deleted file mode 100644 index c12843602b13b..0000000000000 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html +++ /dev/null @@ -1 +0,0 @@ -

{{ ::'plugin_2.message-id' | i18n: { defaultMessage: 'Message text' } }}

diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx new file mode 100644 index 0000000000000..8f41a58bf82d1 --- /dev/null +++ b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx @@ -0,0 +1,8 @@ +/* eslint-disable */ + +i18n.translate('plugin_2.duplicate_id', { defaultMessage: 'Message 1' }); + +i18n.translate('plugin_2.duplicate_id', { + defaultMessage: 'Message 2', + description: 'Message description', +}); diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2_additional_path/test_file.jsx similarity index 100% rename from src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx rename to src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2_additional_path/test_file.jsx diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx deleted file mode 100644 index 7fa370dec5ebb..0000000000000 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable */ - -i18n('plugin_3.duplicate_id', { defaultMessage: 'Message 1' }); - -i18n.translate('plugin_3.duplicate_id', { - defaultMessage: 'Message 2', - description: 'Message description', -}); diff --git a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap index b19b366a8db7b..c9215b9aed98b 100644 --- a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap +++ b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap @@ -5,14 +5,14 @@ Array [ Array [ "plugin_1.id_1", Object { - "description": undefined, + "description": "Message description", "message": "Message 1", }, ], Array [ "plugin_1.id_2", Object { - "description": "Message description", + "description": undefined, "message": "Message 2", }, ], @@ -23,27 +23,13 @@ Array [ "message": "Message 3", }, ], - Array [ - "plugin_1.id_4", - Object { - "description": undefined, - "message": "Message 4", - }, - ], - Array [ - "plugin_1.id_7", - Object { - "description": undefined, - "message": "Message 7", - }, - ], ] `; exports[`dev/i18n/extract_default_translations throws on id collision 1`] = ` Array [ - " I18N ERROR  Error in src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx -Error: There is more than one default message for the same id \\"plugin_3.duplicate_id\\": + " I18N ERROR  Error in src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx +Error: There is more than one default message for the same id \\"plugin_2.duplicate_id\\": \\"Message 1\\" and \\"Message 2\\"", ] `; diff --git a/src/dev/i18n/extract_default_translations.js b/src/dev/i18n/extract_default_translations.js index 97554704edc7f..a453b0bbae2fb 100644 --- a/src/dev/i18n/extract_default_translations.js +++ b/src/dev/i18n/extract_default_translations.js @@ -8,7 +8,7 @@ import path from 'path'; -import { extractHtmlMessages, extractCodeMessages } from './extractors'; +import { extractCodeMessages } from './extractors'; import { globAsync, readFileAsync, normalizePath } from './utils'; import { createFailError, isFailError } from '@kbn/dev-utils'; @@ -59,7 +59,7 @@ export async function matchEntriesWithExctractors(inputPath, options = {}) { '**/*.d.ts', ].concat(additionalIgnore); - const entries = await globAsync('*.{js,jsx,ts,tsx,html}', { + const entries = await globAsync('*.{js,jsx,ts,tsx}', { cwd: inputPath, matchBase: true, ignore, @@ -67,25 +67,14 @@ export async function matchEntriesWithExctractors(inputPath, options = {}) { absolute, }); - const { htmlEntries, codeEntries } = entries.reduce( - (paths, entry) => { - const resolvedPath = path.resolve(inputPath, entry); + const codeEntries = entries.reduce((paths, entry) => { + const resolvedPath = path.resolve(inputPath, entry); + paths.push(resolvedPath); - if (resolvedPath.endsWith('.html')) { - paths.htmlEntries.push(resolvedPath); - } else { - paths.codeEntries.push(resolvedPath); - } - - return paths; - }, - { htmlEntries: [], codeEntries: [] } - ); + return paths; + }, []); - return [ - [htmlEntries, extractHtmlMessages], - [codeEntries, extractCodeMessages], - ]; + return [[codeEntries, extractCodeMessages]]; } export async function extractMessagesFromPathToMap(inputPath, targetMap, config, reporter) { diff --git a/src/dev/i18n/extract_default_translations.test.js b/src/dev/i18n/extract_default_translations.test.js index 4b0da570ca551..e5b33eba7a4db 100644 --- a/src/dev/i18n/extract_default_translations.test.js +++ b/src/dev/i18n/extract_default_translations.test.js @@ -18,17 +18,15 @@ const fixturesPath = path.resolve(__dirname, '__fixtures__', 'extract_default_tr const pluginsPaths = [ path.join(fixturesPath, 'test_plugin_1'), path.join(fixturesPath, 'test_plugin_2'), - path.join(fixturesPath, 'test_plugin_3'), - path.join(fixturesPath, 'test_plugin_3_additional_path'), + path.join(fixturesPath, 'test_plugin_2_additional_path'), ]; const config = { paths: { plugin_1: ['src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1'], - plugin_2: ['src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2'], - plugin_3: [ - 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3', - 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path', + plugin_2: [ + 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2', + 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2_additional_path', ], }, exclude: [], @@ -44,7 +42,7 @@ describe('dev/i18n/extract_default_translations', () => { }); test('throws on id collision', async () => { - const [, , pluginPath] = pluginsPaths; + const [, pluginPath] = pluginsPaths; const reporter = new ErrorReporter(); await expect( @@ -57,20 +55,20 @@ describe('dev/i18n/extract_default_translations', () => { const id = 'plugin_2.message-id'; const filePath = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_2/test_file.html' + '__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx' ); expect(() => validateMessageNamespace(id, filePath, config.paths)).not.toThrow(); }); test('validates message namespace with multiple paths', () => { - const id = 'plugin_3.message-id'; + const id = 'plugin_2.message-id'; const filePath1 = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_3/test_file.html' + '__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx' ); const filePath2 = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.html' + '__fixtures__/extract_default_translations/test_plugin_2_additional_path/test_file.jsx' ); expect(() => validateMessageNamespace(id, filePath1, config.paths)).not.toThrow(); expect(() => validateMessageNamespace(id, filePath2, config.paths)).not.toThrow(); @@ -81,7 +79,7 @@ describe('dev/i18n/extract_default_translations', () => { const id = 'wrong_plugin_namespace.message-id'; const filePath = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_2/test_file.html' + '__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx' ); expect(() => validateMessageNamespace(id, filePath, config.paths, { report })).not.toThrow(); diff --git a/src/dev/i18n/extractors/__snapshots__/html.test.js.snap b/src/dev/i18n/extractors/__snapshots__/html.test.js.snap deleted file mode 100644 index f911674400d45..0000000000000 --- a/src/dev/i18n/extractors/__snapshots__/html.test.js.snap +++ /dev/null @@ -1,77 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`dev/i18n/extractors/html extracts default messages from HTML 1`] = ` -Array [ - Array [ - "kbn.dashboard.id-1", - Object { - "description": "Message description 1", - "message": "Message text 1 {value}", - }, - ], - Array [ - "kbn.dashboard.id-2", - Object { - "description": undefined, - "message": "Message text 2", - }, - ], - Array [ - "kbn.dashboard.id-3", - Object { - "description": "Message description 3", - "message": "Message text 3", - }, - ], -] -`; - -exports[`dev/i18n/extractors/html extracts default messages from HTML with one-time binding 1`] = ` -Array [ - Array [ - "kbn.id", - Object { - "description": undefined, - "message": "Message text with {value}", - }, - ], -] -`; - -exports[`dev/i18n/extractors/html extracts message from i18n filter in interpolating directive 1`] = ` -Array [ - Array [ - "namespace.messageId", - Object { - "description": undefined, - "message": "Message", - }, - ], -] -`; - -exports[`dev/i18n/extractors/html throws on empty i18n-id 1`] = ` -Array [ - Array [ - [Error: Empty "i18n-id" value in angular directive is not allowed.], - ], -] -`; - -exports[`dev/i18n/extractors/html throws on i18n filter usage in complex angular expression 1`] = ` -Array [ - Array [ - [Error: Couldn't parse angular i18n expression: -Missing semicolon. (1:5): - mode as ('metricVis.colorModes.' + mode], - ], -] -`; - -exports[`dev/i18n/extractors/html throws on missing i18n-default-message attribute 1`] = ` -Array [ - Array [ - [Error: Empty defaultMessage in angular directive is not allowed ("message-id").], - ], -] -`; diff --git a/src/dev/i18n/extractors/html.js b/src/dev/i18n/extractors/html.js deleted file mode 100644 index 922f67ac2fb09..0000000000000 --- a/src/dev/i18n/extractors/html.js +++ /dev/null @@ -1,252 +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 cheerio from 'cheerio'; -import { parse } from '@babel/parser'; -import { isObjectExpression, isStringLiteral } from '@babel/types'; - -import { - isPropertyWithKey, - formatHTMLString, - formatJSString, - traverseNodes, - checkValuesProperty, - createParserErrorMessage, - extractMessageValueFromNode, - extractValuesKeysFromNode, - extractDescriptionValueFromNode, -} from '../utils'; -import { DEFAULT_MESSAGE_KEY, DESCRIPTION_KEY, VALUES_KEY } from '../constants'; -import { createFailError, isFailError } from '@kbn/dev-utils'; - -/** - * Find all substrings of "{{ any text }}" pattern allowing '{' and '}' chars in single quote strings - * - * Example: `{{ ::'message.id' | i18n: { defaultMessage: 'Message with {{curlyBraces}}' } }}` - */ -const ANGULAR_EXPRESSION_REGEX = /{{([^{}]|({([^']|('([^']|(\\'))*'))*?}))*}}+/g; - -const LINEBREAK_REGEX = /\n/g; -const I18N_FILTER_MARKER = '| i18n: '; - -function parseExpression(expression) { - let ast; - - try { - ast = parse(`+${expression}`.replace(LINEBREAK_REGEX, ' ')); - } catch (error) { - if (error instanceof SyntaxError) { - const errorWithContext = createParserErrorMessage(` ${expression}`, error); - throw createFailError(`Couldn't parse angular i18n expression:\n${errorWithContext}`); - } - } - - return ast; -} - -/** - * Extract default message from an angular filter expression argument - * @param {string} expression JavaScript code containing a filter object - * @param {string} messageId id of the message - * @returns {{ message?: string, description?: string, valuesKeys: string[]] }} - */ -function parseFilterObjectExpression(expression, messageId) { - const ast = parseExpression(expression); - const objectExpressionNode = [...traverseNodes(ast.program.body)].find((node) => - isObjectExpression(node) - ); - - if (!objectExpressionNode) { - return {}; - } - - const [messageProperty, descriptionProperty, valuesProperty] = [ - DEFAULT_MESSAGE_KEY, - DESCRIPTION_KEY, - VALUES_KEY, - ].map((key) => - objectExpressionNode.properties.find((property) => isPropertyWithKey(property, key)) - ); - - const message = messageProperty - ? formatJSString(extractMessageValueFromNode(messageProperty.value, messageId)) - : undefined; - - const description = descriptionProperty - ? formatJSString(extractDescriptionValueFromNode(descriptionProperty.value, messageId)) - : undefined; - - const valuesKeys = valuesProperty - ? extractValuesKeysFromNode(valuesProperty.value, messageId) - : []; - - return { message, description, valuesKeys }; -} - -function parseIdExpression(expression) { - const ast = parseExpression(expression); - const stringNode = [...traverseNodes(ast.program.body)].find((node) => isStringLiteral(node)); - - if (!stringNode) { - throw createFailError(`Message id should be a string literal, but got: \n${expression}`); - } - - return stringNode ? formatJSString(stringNode.value) : null; -} - -function trimCurlyBraces(string) { - if (string.startsWith('{{') && string.endsWith('}}')) { - return string.slice(2, -2).trim(); - } - - return string; -} - -/** - * Removes one-time binding operator `::` from the start of a string. - * - * Example: `::'id' | i18n: { defaultMessage: 'Message' }` - * @param {string} string string to trim - */ -function trimOneTimeBindingOperator(string) { - if (string.startsWith('::')) { - return string.slice(2); - } - - return string; -} - -function* extractExpressions(htmlContent) { - const elements = cheerio.load(htmlContent)('*').toArray(); - - for (const element of elements) { - for (const node of element.children) { - if (node.type === 'text') { - yield* (node.data.match(ANGULAR_EXPRESSION_REGEX) || []) - .filter((expression) => expression.includes(I18N_FILTER_MARKER)) - .map(trimCurlyBraces); - } - } - - for (const attribute of Object.values(element.attribs)) { - if (attribute.includes(I18N_FILTER_MARKER)) { - yield trimCurlyBraces(attribute); - } - } - } -} - -function* getFilterMessages(htmlContent, reporter) { - for (const expression of extractExpressions(htmlContent)) { - const filterStart = expression.indexOf(I18N_FILTER_MARKER); - - const idExpression = trimOneTimeBindingOperator(expression.slice(0, filterStart).trim()); - const filterObjectExpression = expression.slice(filterStart + I18N_FILTER_MARKER.length).trim(); - - try { - if (!filterObjectExpression || !idExpression) { - throw createFailError(`Cannot parse i18n filter expression: ${expression}`); - } - - const messageId = parseIdExpression(idExpression); - - if (!messageId) { - throw createFailError('Empty "id" value in angular filter expression is not allowed.'); - } - - const { message, description, valuesKeys } = parseFilterObjectExpression( - filterObjectExpression, - messageId - ); - - if (!message) { - throw createFailError( - `Empty defaultMessage in angular filter expression is not allowed ("${messageId}").` - ); - } - - checkValuesProperty(valuesKeys, message, messageId); - - yield [messageId, { message, description }]; - } catch (error) { - if (!isFailError(error)) { - throw error; - } - - reporter.report(error); - } - } -} - -function* getDirectiveMessages(htmlContent, reporter) { - const $ = cheerio.load(htmlContent); - - const elements = $('[i18n-id]') - .map((idx, el) => { - const $el = $(el); - - return { - id: $el.attr('i18n-id'), - defaultMessage: $el.attr('i18n-default-message'), - description: $el.attr('i18n-description'), - values: $el.attr('i18n-values'), - }; - }) - .toArray(); - - for (const element of elements) { - const messageId = formatHTMLString(element.id); - if (!messageId) { - reporter.report( - createFailError('Empty "i18n-id" value in angular directive is not allowed.') - ); - continue; - } - - const message = formatHTMLString(element.defaultMessage); - if (!message) { - reporter.report( - createFailError( - `Empty defaultMessage in angular directive is not allowed ("${messageId}").` - ) - ); - continue; - } - - try { - if (element.values) { - const ast = parseExpression(element.values); - const valuesObjectNode = [...traverseNodes(ast.program.body)].find((node) => - isObjectExpression(node) - ); - const valuesKeys = extractValuesKeysFromNode(valuesObjectNode); - - checkValuesProperty(valuesKeys, message, messageId); - } else { - checkValuesProperty([], message, messageId); - } - - yield [ - messageId, - { message, description: formatHTMLString(element.description) || undefined }, - ]; - } catch (error) { - if (!isFailError(error)) { - throw error; - } - - reporter.report(error); - } - } -} - -export function* extractHtmlMessages(buffer, reporter) { - const content = buffer.toString(); - yield* getDirectiveMessages(content, reporter); - yield* getFilterMessages(content, reporter); -} diff --git a/src/dev/i18n/extractors/html.test.js b/src/dev/i18n/extractors/html.test.js deleted file mode 100644 index 5f0c7ac39e8f6..0000000000000 --- a/src/dev/i18n/extractors/html.test.js +++ /dev/null @@ -1,103 +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 { extractHtmlMessages } from './html'; - -const htmlSourceBuffer = Buffer.from(` -
-
-

-
-
- {{ 'kbn.dashboard.id-2' | i18n: { defaultMessage: 'Message text 2' } }} -
-
- {{ 'kbn.dashboard.id-3' | i18n: { defaultMessage: 'Message text 3', description: 'Message description 3' } }} -
-
-`); - -const report = jest.fn(); - -describe('dev/i18n/extractors/html', () => { - beforeEach(() => { - report.mockClear(); - }); - - test('extracts default messages from HTML', () => { - const actual = Array.from(extractHtmlMessages(htmlSourceBuffer)); - expect(actual.sort()).toMatchSnapshot(); - }); - - test('extracts default messages from HTML with one-time binding', () => { - const actual = Array.from( - extractHtmlMessages(` -
- {{::'kbn.id' | i18n: { defaultMessage: 'Message text with {value}', values: { value: 'value' } }}} -
-`) - ); - expect(actual.sort()).toMatchSnapshot(); - }); - - test('throws on empty i18n-id', () => { - const source = Buffer.from(`\ -

-`); - - expect(() => extractHtmlMessages(source, { report }).next()).not.toThrow(); - expect(report.mock.calls).toMatchSnapshot(); - }); - - test('throws on missing i18n-default-message attribute', () => { - const source = Buffer.from(`\ -

-`); - - expect(() => extractHtmlMessages(source, { report }).next()).not.toThrow(); - expect(report.mock.calls).toMatchSnapshot(); - }); - - test('throws on i18n filter usage in complex angular expression', () => { - const source = Buffer.from(`\ -
-`); - - expect(() => extractHtmlMessages(source, { report }).next()).not.toThrow(); - expect(report.mock.calls).toMatchSnapshot(); - }); - - test('extracts message from i18n filter in interpolating directive', () => { - const source = Buffer.from(` - -`); - - expect(Array.from(extractHtmlMessages(source))).toMatchSnapshot(); - }); -}); diff --git a/src/dev/i18n/extractors/index.js b/src/dev/i18n/extractors/index.js index dc269586f5abe..601a080c80b1b 100644 --- a/src/dev/i18n/extractors/index.js +++ b/src/dev/i18n/extractors/index.js @@ -7,4 +7,3 @@ */ export { extractCodeMessages } from './code'; -export { extractHtmlMessages } from './html'; diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index a4ae39848735e..efa54e74fdf2f 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -75,6 +75,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@7.16.0': ['Elastic License 2.0'], - '@elastic/eui@39.0.0': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@39.1.1': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/console/public/index.ts b/src/plugins/console/public/index.ts index 8c4a107108565..9a9c5896cd26d 100644 --- a/src/plugins/console/public/index.ts +++ b/src/plugins/console/public/index.ts @@ -7,13 +7,14 @@ */ import './index.scss'; +import { PluginInitializerContext } from 'src/core/public'; import { ConsoleUIPlugin } from './plugin'; -export type { ConsoleUILocatorParams } from './plugin'; +export type { ConsoleUILocatorParams, ConsolePluginSetup } from './types'; export { ConsoleUIPlugin as Plugin }; -export function plugin() { - return new ConsoleUIPlugin(); +export function plugin(ctx: PluginInitializerContext) { + return new ConsoleUIPlugin(ctx); } diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index e3791df6a2db6..d61769c23dfe0 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -7,77 +7,87 @@ */ import { i18n } from '@kbn/i18n'; -import { SerializableRecord } from '@kbn/utility-types'; -import { Plugin, CoreSetup } from 'src/core/public'; +import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/public'; import { FeatureCatalogueCategory } from '../../home/public'; -import { AppSetupUIPluginDependencies } from './types'; - -export interface ConsoleUILocatorParams extends SerializableRecord { - loadFrom?: string; -} +import { + AppSetupUIPluginDependencies, + ClientConfigType, + ConsolePluginSetup, + ConsoleUILocatorParams, +} from './types'; export class ConsoleUIPlugin implements Plugin { + constructor(private ctx: PluginInitializerContext) {} + public setup( { notifications, getStartServices, http }: CoreSetup, { devTools, home, share, usageCollection }: AppSetupUIPluginDependencies - ) { - if (home) { - home.featureCatalogue.register({ + ): ConsolePluginSetup { + const { + ui: { enabled: isConsoleUiEnabled }, + } = this.ctx.config.get(); + + if (isConsoleUiEnabled) { + if (home) { + home.featureCatalogue.register({ + id: 'console', + title: i18n.translate('console.devToolsTitle', { + defaultMessage: 'Interact with the Elasticsearch API', + }), + description: i18n.translate('console.devToolsDescription', { + defaultMessage: 'Skip cURL and use a JSON interface to work with your data in Console.', + }), + icon: 'consoleApp', + path: '/app/dev_tools#/console', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + } + + devTools.register({ id: 'console', - title: i18n.translate('console.devToolsTitle', { - defaultMessage: 'Interact with the Elasticsearch API', - }), - description: i18n.translate('console.devToolsDescription', { - defaultMessage: 'Skip cURL and use a JSON interface to work with your data in Console.', + order: 1, + title: i18n.translate('console.consoleDisplayName', { + defaultMessage: 'Console', }), - icon: 'consoleApp', - path: '/app/dev_tools#/console', - showOnHomePage: false, - category: FeatureCatalogueCategory.ADMIN, - }); - } + enableRouting: false, + mount: async ({ element }) => { + const [core] = await getStartServices(); - devTools.register({ - id: 'console', - order: 1, - title: i18n.translate('console.consoleDisplayName', { - defaultMessage: 'Console', - }), - enableRouting: false, - mount: async ({ element }) => { - const [core] = await getStartServices(); + const { + i18n: { Context: I18nContext }, + docLinks: { DOC_LINK_VERSION }, + } = core; - const { - i18n: { Context: I18nContext }, - docLinks: { DOC_LINK_VERSION }, - } = core; + const { renderApp } = await import('./application'); - const { renderApp } = await import('./application'); + return renderApp({ + http, + docLinkVersion: DOC_LINK_VERSION, + I18nContext, + notifications, + usageCollection, + element, + }); + }, + }); - return renderApp({ - http, - docLinkVersion: DOC_LINK_VERSION, - I18nContext, - notifications, - usageCollection, - element, - }); - }, - }); + const locator = share.url.locators.create({ + id: 'CONSOLE_APP_LOCATOR', + getLocation: async ({ loadFrom }) => { + return { + app: 'dev_tools', + path: `#/console${loadFrom ? `?load_from=${loadFrom}` : ''}`, + state: { loadFrom }, + }; + }, + }); - const locator = share.url.locators.create({ - id: 'CONSOLE_APP_LOCATOR', - getLocation: async ({ loadFrom }) => { - return { - app: 'dev_tools', - path: `#/console${loadFrom ? `?load_from=${loadFrom}` : ''}`, - state: { loadFrom }, - }; - }, - }); + return { locator }; + } - return { locator }; + return {}; } public start() {} diff --git a/src/plugins/console/public/types/config.ts b/src/plugins/console/public/types/config.ts new file mode 100644 index 0000000000000..da41eef6f5484 --- /dev/null +++ b/src/plugins/console/public/types/config.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface ClientConfigType { + ui: { + enabled: boolean; + }; +} diff --git a/src/plugins/console/public/types/index.ts b/src/plugins/console/public/types/index.ts index b98adbf5610cd..d8b6aaf7b12c4 100644 --- a/src/plugins/console/public/types/index.ts +++ b/src/plugins/console/public/types/index.ts @@ -11,3 +11,5 @@ export * from './core_editor'; export * from './token'; export * from './tokens_provider'; export * from './common'; +export { ClientConfigType } from './config'; +export { ConsoleUILocatorParams } from './locator'; diff --git a/src/plugins/console/public/types/locator.ts b/src/plugins/console/public/types/locator.ts new file mode 100644 index 0000000000000..f3a42338aaadc --- /dev/null +++ b/src/plugins/console/public/types/locator.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 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 { SerializableRecord } from '@kbn/utility-types'; + +export interface ConsoleUILocatorParams extends SerializableRecord { + loadFrom?: string; +} diff --git a/src/plugins/console/public/types/plugin_dependencies.ts b/src/plugins/console/public/types/plugin_dependencies.ts index 444776f47ea13..afc49f9a5a986 100644 --- a/src/plugins/console/public/types/plugin_dependencies.ts +++ b/src/plugins/console/public/types/plugin_dependencies.ts @@ -9,7 +9,9 @@ import { HomePublicPluginSetup } from '../../../home/public'; import { DevToolsSetup } from '../../../dev_tools/public'; import { UsageCollectionSetup } from '../../../usage_collection/public'; -import { SharePluginSetup } from '../../../share/public'; +import { SharePluginSetup, LocatorPublic } from '../../../share/public'; + +import { ConsoleUILocatorParams } from './locator'; export interface AppSetupUIPluginDependencies { home?: HomePublicPluginSetup; @@ -17,3 +19,7 @@ export interface AppSetupUIPluginDependencies { share: SharePluginSetup; usageCollection?: UsageCollectionSetup; } + +export interface ConsolePluginSetup { + locator?: LocatorPublic; +} diff --git a/src/plugins/console/server/config.ts b/src/plugins/console/server/config.ts index 6d667fed081e8..024777aa8d252 100644 --- a/src/plugins/console/server/config.ts +++ b/src/plugins/console/server/config.ts @@ -7,6 +7,8 @@ */ import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; import { PluginConfigDescriptor } from 'kibana/server'; @@ -14,62 +16,171 @@ import { MAJOR_VERSION } from '../common/constants'; const kibanaVersion = new SemVer(MAJOR_VERSION); -const baseSettings = { - enabled: schema.boolean({ defaultValue: true }), - ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), -}; - -// Settings only available in 7.x -const deprecatedSettings = { - proxyFilter: schema.arrayOf(schema.string(), { defaultValue: ['.*'] }), - proxyConfig: schema.arrayOf( - schema.object({ - match: schema.object({ - protocol: schema.string({ defaultValue: '*' }), - host: schema.string({ defaultValue: '*' }), - port: schema.string({ defaultValue: '*' }), - path: schema.string({ defaultValue: '*' }), - }), - - timeout: schema.number(), - ssl: schema.object( - { - verify: schema.boolean(), - ca: schema.arrayOf(schema.string()), - cert: schema.string(), - key: schema.string(), - }, - { defaultValue: undefined } - ), - }), - { defaultValue: [] } - ), -}; - -const configSchema = schema.object( +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( { - ...baseSettings, + ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), }, { defaultValue: undefined } ); -const configSchema7x = schema.object( +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; + +export type ConsoleConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( { - ...baseSettings, - ...deprecatedSettings, + enabled: schema.boolean({ defaultValue: true }), + proxyFilter: schema.arrayOf(schema.string(), { defaultValue: ['.*'] }), + proxyConfig: schema.arrayOf( + schema.object({ + match: schema.object({ + protocol: schema.string({ defaultValue: '*' }), + host: schema.string({ defaultValue: '*' }), + port: schema.string({ defaultValue: '*' }), + path: schema.string({ defaultValue: '*' }), + }), + + timeout: schema.number(), + ssl: schema.object( + { + verify: schema.boolean(), + ca: schema.arrayOf(schema.string()), + cert: schema.string(), + key: schema.string(), + }, + { defaultValue: undefined } + ), + }), + { defaultValue: [] } + ), + ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), }, { defaultValue: undefined } ); -export type ConfigType = TypeOf; -export type ConfigType7x = TypeOf; +export type ConsoleConfig7x = TypeOf; -export const config: PluginConfigDescriptor = { - schema: kibanaVersion.major < 8 ? configSchema7x : configSchema, +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schema7x, deprecations: ({ deprecate, unused }) => [ - deprecate('enabled', '8.0.0'), - deprecate('proxyFilter', '8.0.0'), - deprecate('proxyConfig', '8.0.0'), unused('ssl'), + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'console.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'console.enabled', + level: 'critical', + title: i18n.translate('console.deprecations.enabledTitle', { + defaultMessage: 'Setting "console.enabled" is deprecated', + }), + message: i18n.translate('console.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Console UI, use the "console.ui.enabled" setting instead of "console.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('console.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('console.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: 'Change the "console.enabled" setting to "console.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'console.proxyConfig') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'console.proxyConfig', + level: 'critical', + title: i18n.translate('console.deprecations.proxyConfigTitle', { + defaultMessage: 'Setting "console.proxyConfig" is deprecated', + }), + message: i18n.translate('console.deprecations.proxyConfigMessage', { + defaultMessage: + 'Configuring "console.proxyConfig" is deprecated and will be removed in 8.0.0. To secure your connection between Kibana and Elasticsearch use the standard "server.ssl.*" settings instead.', + }), + documentationUrl: 'https://ela.st/encrypt-kibana-browser', + correctiveActions: { + manualSteps: [ + i18n.translate('console.deprecations.proxyConfig.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('console.deprecations.proxyConfig.manualStepTwoMessage', { + defaultMessage: 'Remove the "console.proxyConfig" setting.', + }), + i18n.translate('console.deprecations.proxyConfig.manualStepThreeMessage', { + defaultMessage: + 'Configure the secure connection between Kibana and Elasticsearch using the "server.ssl.*" settings.', + }), + ], + }, + }); + return completeConfig; + }, + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'console.proxyFilter') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'console.proxyFilter', + level: 'critical', + title: i18n.translate('console.deprecations.proxyFilterTitle', { + defaultMessage: 'Setting "console.proxyFilter" is deprecated', + }), + message: i18n.translate('console.deprecations.proxyFilterMessage', { + defaultMessage: + 'Configuring "console.proxyFilter" is deprecated and will be removed in 8.0.0. To secure your connection between Kibana and Elasticsearch use the standard "server.ssl.*" settings instead.', + }), + documentationUrl: 'https://ela.st/encrypt-kibana-browser', + correctiveActions: { + manualSteps: [ + i18n.translate('console.deprecations.proxyFilter.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('console.deprecations.proxyFilter.manualStepTwoMessage', { + defaultMessage: 'Remove the "console.proxyFilter" setting.', + }), + i18n.translate('console.deprecations.proxyFilter.manualStepThreeMessage', { + defaultMessage: + 'Configure the secure connection between Kibana and Elasticsearch using the "server.ssl.*" settings.', + }), + ], + }, + }); + return completeConfig; + }, ], }; + +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/src/plugins/console/server/index.ts b/src/plugins/console/server/index.ts index 6ae518f5dc796..b270b89a3d45a 100644 --- a/src/plugins/console/server/index.ts +++ b/src/plugins/console/server/index.ts @@ -11,6 +11,7 @@ import { PluginInitializerContext } from 'kibana/server'; import { ConsoleServerPlugin } from './plugin'; export { ConsoleSetup, ConsoleStart } from './types'; + export { config } from './config'; export const plugin = (ctx: PluginInitializerContext) => new ConsoleServerPlugin(ctx); diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index 613337b286fbf..5543c40d03cb0 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -11,7 +11,7 @@ import { SemVer } from 'semver'; import { ProxyConfigCollection } from './lib'; import { SpecDefinitionsService, EsLegacyConfigService } from './services'; -import { ConfigType, ConfigType7x } from './config'; +import { ConsoleConfig, ConsoleConfig7x } from './config'; import { registerRoutes } from './routes'; @@ -24,11 +24,11 @@ export class ConsoleServerPlugin implements Plugin { esLegacyConfigService = new EsLegacyConfigService(); - constructor(private readonly ctx: PluginInitializerContext) { + constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } - setup({ http, capabilities, getStartServices, elasticsearch }: CoreSetup) { + setup({ http, capabilities, elasticsearch }: CoreSetup) { capabilities.registerProvider(() => ({ dev_tools: { show: true, @@ -43,8 +43,8 @@ export class ConsoleServerPlugin implements Plugin { let proxyConfigCollection: ProxyConfigCollection | undefined; if (kibanaVersion.major < 8) { // "pathFilters" and "proxyConfig" are only used in 7.x - pathFilters = (config as ConfigType7x).proxyFilter.map((str: string) => new RegExp(str)); - proxyConfigCollection = new ProxyConfigCollection((config as ConfigType7x).proxyConfig); + pathFilters = (config as ConsoleConfig7x).proxyFilter.map((str: string) => new RegExp(str)); + proxyConfigCollection = new ProxyConfigCollection((config as ConsoleConfig7x).proxyConfig); } this.esLegacyConfigService.setup(elasticsearch.legacy.config$); diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index 8772f14a6ec4c..4afb42aa841bb 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -114,14 +114,14 @@ export function createSavedDashboardClass( }, // if this is null/undefined then the SavedObject will be assigned the defaults - id: typeof arg === 'string' ? arg : arg.id, + id: typeof arg === 'object' ? arg.id : arg, // default values that will get assigned if the doc is new defaults, }); - const id: string = typeof arg === 'string' ? arg : arg.id; - const useResolve = typeof arg === 'string' ? false : arg.useResolve; + const id: string = typeof arg === 'object' ? arg.id : arg; + const useResolve = typeof arg === 'object' ? arg.useResolve : false; this.getFullPath = () => `/app/dashboards#${createDashboardEditUrl(this.aliasId || this.id)}`; diff --git a/src/plugins/data/common/search/aggs/metrics/single_percentile.test.ts b/src/plugins/data/common/search/aggs/metrics/single_percentile.test.ts index c2ba6ee1a403a..967e1b1f624aa 100644 --- a/src/plugins/data/common/search/aggs/metrics/single_percentile.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/single_percentile.test.ts @@ -73,6 +73,11 @@ describe('AggTypeMetricSinglePercentileProvider class', () => { ).toEqual(123); }); + it('should not throw error for empty buckets', () => { + const agg = aggConfigs.getResponseAggs()[0]; + expect(agg.getValue({})).toEqual(NaN); + }); + it('produces the expected expression ast', () => { const agg = aggConfigs.getResponseAggs()[0]; expect(agg.toExpressionAst()).toMatchInlineSnapshot(` diff --git a/src/plugins/data/common/search/aggs/metrics/single_percentile.ts b/src/plugins/data/common/search/aggs/metrics/single_percentile.ts index 4bdafcae327cd..954576e2bbe1f 100644 --- a/src/plugins/data/common/search/aggs/metrics/single_percentile.ts +++ b/src/plugins/data/common/search/aggs/metrics/single_percentile.ts @@ -57,7 +57,9 @@ export const getSinglePercentileMetricAgg = () => { if (Number.isInteger(agg.params.percentile)) { valueKey += '.0'; } - return bucket[agg.id].values[valueKey]; + const { values } = bucket[agg.id] ?? {}; + + return values ? values[valueKey] : NaN; }, }); }; diff --git a/src/plugins/data/common/search/tabify/tabify_docs.test.ts b/src/plugins/data/common/search/tabify/tabify_docs.test.ts index a2910a1be4a9a..1964247b09585 100644 --- a/src/plugins/data/common/search/tabify/tabify_docs.test.ts +++ b/src/plugins/data/common/search/tabify/tabify_docs.test.ts @@ -41,6 +41,11 @@ function create(id: string) { }); } +const meta = { + _index: 'index-name', + _id: '1', +}; + describe('tabify_docs', () => { describe('flattenHit', () => { let indexPattern: DataView; @@ -70,6 +75,50 @@ describe('tabify_docs', () => { expect(Object.keys(response)).toEqual(expectedOrder); expect(Object.entries(response).map(([key]) => key)).toEqual(expectedOrder); }); + + it('does merge values from ignored_field_values and fields correctly', () => { + const flatten = flattenHit( + { + ...meta, + fields: { 'extension.keyword': ['foo'], extension: ['foo', 'ignored'] }, + ignored_field_values: { + 'extension.keyword': ['ignored'], + fully_ignored: ['some', 'value'], + }, + }, + indexPattern, + { includeIgnoredValues: true } + ); + expect(flatten).toHaveProperty(['extension.keyword'], ['foo', 'ignored']); + expect(flatten).toHaveProperty('extension', ['foo', 'ignored']); + expect(flatten).toHaveProperty('fully_ignored', ['some', 'value']); + }); + + it('does not merge values from ignored_field_values into _source', () => { + const flatten = flattenHit( + { + ...meta, + _source: { 'extension.keyword': ['foo', 'ignored'] }, + ignored_field_values: { 'extension.keyword': ['ignored'] }, + }, + indexPattern, + { includeIgnoredValues: true, source: true } + ); + expect(flatten).toHaveProperty(['extension.keyword'], ['foo', 'ignored']); + }); + + it('does merge ignored_field_values when no _source was present, even when parameter was on', () => { + const flatten = flattenHit( + { + ...meta, + fields: { 'extension.keyword': ['foo'] }, + ignored_field_values: { 'extension.keyword': ['ignored'] }, + }, + indexPattern, + { includeIgnoredValues: true, source: true } + ); + expect(flatten).toHaveProperty(['extension.keyword'], ['foo', 'ignored']); + }); }); describe('tabifyDocs', () => { diff --git a/src/plugins/data/common/search/tabify/tabify_docs.ts b/src/plugins/data/common/search/tabify/tabify_docs.ts index 4259488771761..353a0c10ba12a 100644 --- a/src/plugins/data/common/search/tabify/tabify_docs.ts +++ b/src/plugins/data/common/search/tabify/tabify_docs.ts @@ -55,8 +55,18 @@ export interface TabifyDocsOptions { * merged into the flattened document. */ source?: boolean; + /** + * If set to `true` values that have been ignored in ES (ignored_field_values) + * will be merged into the flattened document. This will only have an effect if + * the `hit` has been retrieved using the `fields` option. + */ + includeIgnoredValues?: boolean; } +// This is an overwrite of the SearchHit type to add the ignored_field_values. +// Can be removed once the estypes.SearchHit knows about ignored_field_values +type Hit = estypes.SearchHit & { ignored_field_values?: Record }; + /** * Flattens an individual hit (from an ES response) into an object. This will * create flattened field names, like `user.name`. @@ -65,11 +75,7 @@ export interface TabifyDocsOptions { * @param indexPattern The index pattern for the requested index if available. * @param params Parameters how to flatten the hit */ -export function flattenHit( - hit: estypes.SearchHit, - indexPattern?: IndexPattern, - params?: TabifyDocsOptions -) { +export function flattenHit(hit: Hit, indexPattern?: IndexPattern, params?: TabifyDocsOptions) { const flat = {} as Record; function flatten(obj: Record, keyPrefix: string = '') { @@ -109,6 +115,28 @@ export function flattenHit( flatten(hit.fields || {}); if (params?.source !== false && hit._source) { flatten(hit._source as Record); + } else if (params?.includeIgnoredValues && hit.ignored_field_values) { + // If enabled merge the ignored_field_values into the flattened hit. This will + // merge values that are not actually indexed by ES (i.e. ignored), e.g. because + // they were above the `ignore_above` limit or malformed for specific types. + // This API will only contain the values that were actually ignored, i.e. for the same + // field there might exist another value in the `fields` response, why this logic + // merged them both together. We do not merge this (even if enabled) in case source has been + // merged, since we would otherwise duplicate values, since ignore_field_values and _source + // contain the same values. + Object.entries(hit.ignored_field_values).forEach(([fieldName, fieldValue]) => { + if (flat[fieldName]) { + // If there was already a value from the fields API, make sure we're merging both together + if (Array.isArray(flat[fieldName])) { + flat[fieldName] = [...flat[fieldName], ...fieldValue]; + } else { + flat[fieldName] = [flat[fieldName], ...fieldValue]; + } + } else { + // If no previous value was assigned we can simply use the value from `ignored_field_values` as it is + flat[fieldName] = fieldValue; + } + }); } // Merge all valid meta fields into the flattened object diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx index b7ec5a1f0c286..0541e12cf8172 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx @@ -103,7 +103,8 @@ function wrapQueryBarTopRowInContext(testProps: any) { ); } -describe('QueryBarTopRowTopRow', () => { +// Failing: See https://github.com/elastic/kibana/issues/92528 +describe.skip('QueryBarTopRowTopRow', () => { const QUERY_INPUT_SELECTOR = 'QueryStringInputUI'; const TIMEPICKER_SELECTOR = 'EuiSuperDatePicker'; const TIMEPICKER_DURATION = '[data-shared-timefilter-duration]'; diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index eae2032748396..7773f2209bf96 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -1220,93 +1220,75 @@ exports[`Inspector Data View component should render single table without select - -
- -
- - - +
+
+ + + - -
- - - : - 20 - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" +
-
-
+ - - + + + +
-
-
-
-
- -
+
+
+ - - - -
- -
-
-
-
- + + +
+ + + + + +
+ @@ -2791,93 +2806,75 @@ exports[`Inspector Data View component should support multiple datatables 1`] = - -
- -
- - - +
+
+ + + - -
- - - : - 20 - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" +
-
-
+ - - + + + +
-
-
-
-
- -
+
+
+ - - - -
- -
-
-
-
- + + +
+ + + + + +
+ diff --git a/src/plugins/data_views/common/data_views/data_view.ts b/src/plugins/data_views/common/data_views/data_view.ts index 57db127208dc3..b7823677b70f9 100644 --- a/src/plugins/data_views/common/data_views/data_view.ts +++ b/src/plugins/data_views/common/data_views/data_view.ts @@ -17,7 +17,6 @@ import { DuplicateField } from '../../../kibana_utils/common'; import { IIndexPattern, IFieldType } from '../../common'; import { DataViewField, IIndexPatternFieldList, fieldList } from '../fields'; -import { formatHitProvider } from './format_hit'; import { flattenHitWrapper } from './flatten_hit'; import { FieldFormatsStartCommon, @@ -45,8 +44,6 @@ interface SavedObjectBody { type?: string; } -type FormatFieldFn = (hit: Record, fieldName: string) => any; - export class DataView implements IIndexPattern { public id?: string; public title: string = ''; @@ -67,11 +64,6 @@ export class DataView implements IIndexPattern { * Type is used to identify rollup index patterns */ public type: string | undefined; - public formatHit: { - (hit: Record, type?: string): any; - formatField: FormatFieldFn; - }; - public formatField: FormatFieldFn; /** * @deprecated Use `flattenHit` utility method exported from data plugin instead. */ @@ -103,11 +95,6 @@ export class DataView implements IIndexPattern { this.fields = fieldList([], this.shortDotsEnable); this.flattenHit = flattenHitWrapper(this, metaFields); - this.formatHit = formatHitProvider( - this, - fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.STRING) - ); - this.formatField = this.formatHit.formatField; // set values this.id = spec.id; diff --git a/src/plugins/data_views/common/data_views/format_hit.ts b/src/plugins/data_views/common/data_views/format_hit.ts deleted file mode 100644 index c8e6e8e337155..0000000000000 --- a/src/plugins/data_views/common/data_views/format_hit.ts +++ /dev/null @@ -1,74 +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'; -import { DataView } from './data_view'; -import { FieldFormatsContentType } from '../../../field_formats/common'; - -const formattedCache = new WeakMap(); -const partialFormattedCache = new WeakMap(); - -// Takes a hit, merges it with any stored/scripted fields, and with the metaFields -// returns a formatted version -export function formatHitProvider(dataView: DataView, defaultFormat: any) { - function convert( - hit: Record, - val: any, - fieldName: string, - type: FieldFormatsContentType = 'html' - ) { - const field = dataView.fields.getByName(fieldName); - const format = field ? dataView.getFormatterForField(field) : defaultFormat; - - return format.convert(val, type, { field, hit, indexPattern: dataView }); - } - - function formatHit(hit: Record, type: string = 'html') { - const cached = formattedCache.get(hit); - if (cached) { - return cached; - } - - // use and update the partial cache, but don't rewrite it. - // _source is stored in partialFormattedCache but not formattedCache - const partials = partialFormattedCache.get(hit) || {}; - partialFormattedCache.set(hit, partials); - - const cache: Record = {}; - formattedCache.set(hit, cache); - - _.forOwn(dataView.flattenHit(hit), function (val: any, fieldName?: string) { - // sync the formatted and partial cache - if (!fieldName) { - return; - } - const formatted = - partials[fieldName] == null ? convert(hit, val, fieldName) : partials[fieldName]; - cache[fieldName] = partials[fieldName] = formatted; - }); - - return cache; - } - - formatHit.formatField = function (hit: Record, fieldName: string) { - let partials = partialFormattedCache.get(hit); - if (partials && partials[fieldName] != null) { - return partials[fieldName]; - } - - if (!partials) { - partials = {}; - partialFormattedCache.set(hit, partials); - } - - const val = fieldName === '_source' ? hit._source : dataView.flattenHit(hit)[fieldName]; - return convert(hit, val, fieldName); - }; - - return formatHit; -} diff --git a/src/plugins/data_views/common/data_views/index.ts b/src/plugins/data_views/common/data_views/index.ts index 7c94dff961c9c..d925d42fbea0d 100644 --- a/src/plugins/data_views/common/data_views/index.ts +++ b/src/plugins/data_views/common/data_views/index.ts @@ -8,6 +8,5 @@ export * from './_pattern_cache'; export * from './flatten_hit'; -export * from './format_hit'; export * from './data_view'; export * from './data_views'; diff --git a/src/plugins/data_views/public/index.ts b/src/plugins/data_views/public/index.ts index 5c810ec1fd4c8..3a6b5ccb237f2 100644 --- a/src/plugins/data_views/public/index.ts +++ b/src/plugins/data_views/public/index.ts @@ -13,7 +13,7 @@ export { ILLEGAL_CHARACTERS, validateDataView, } from '../common/lib'; -export { formatHitProvider, onRedirectNoIndexPattern } from './data_views'; +export { onRedirectNoIndexPattern } from './data_views'; export { IndexPatternField, IIndexPatternFieldList, TypeMeta } from '../common'; diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index 3d5fdefd276d3..791ce54a0cb1b 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -8,6 +8,7 @@ "data", "embeddable", "inspector", + "fieldFormats", "kibanaLegacy", "urlForwarding", "navigation", @@ -16,7 +17,7 @@ "indexPatternFieldEditor" ], "optionalPlugins": ["home", "share", "usageCollection", "spaces"], - "requiredBundles": ["kibanaUtils", "home", "kibanaReact", "fieldFormats", "dataViews"], + "requiredBundles": ["kibanaUtils", "home", "kibanaReact", "dataViews"], "extraPublicDirs": ["common"], "owner": { "name": "Data Discovery", diff --git a/src/plugins/discover/public/__mocks__/index_pattern.ts b/src/plugins/discover/public/__mocks__/index_pattern.ts index 2acb512617a6b..d33445baa0a2b 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; -import { flattenHit, IIndexPatternFieldList } from '../../../data/common'; +import { IIndexPatternFieldList } from '../../../data/common'; import { IndexPattern } from '../../../data/common'; const fields = [ @@ -28,6 +27,7 @@ const fields = [ { name: 'message', type: 'string', + displayName: 'message', scripted: false, filterable: false, aggregatable: false, @@ -35,6 +35,7 @@ const fields = [ { name: 'extension', type: 'string', + displayName: 'extension', scripted: false, filterable: true, aggregatable: true, @@ -42,6 +43,7 @@ const fields = [ { name: 'bytes', type: 'number', + displayName: 'bytesDisplayName', scripted: false, filterable: true, aggregatable: true, @@ -49,12 +51,14 @@ const fields = [ { name: 'scripted', type: 'number', + displayName: 'scripted', scripted: true, filterable: false, }, { name: 'object.value', type: 'number', + displayName: 'object.value', scripted: false, filterable: true, aggregatable: true, @@ -73,23 +77,15 @@ const indexPattern = { id: 'the-index-pattern-id', title: 'the-index-pattern-title', metaFields: ['_index', '_score'], - formatField: jest.fn(), - flattenHit: undefined, - formatHit: jest.fn((hit) => (hit.fields ? hit.fields : hit._source)), fields, getComputedFields: () => ({ docvalueFields: [], scriptFields: {}, storedFields: ['*'] }), getSourceFiltering: () => ({}), getFieldByName: jest.fn(() => ({})), timeFieldName: '', docvalueFields: [], - getFormatterForField: () => ({ convert: () => 'formatted' }), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), } as unknown as IndexPattern; indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; -indexPattern.formatField = (hit: Record, fieldName: string) => { - return fieldName === '_source' - ? hit._source - : flattenHit(hit as unknown as estypes.SearchHit, indexPattern)[fieldName]; -}; export const indexPatternMock = indexPattern; diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts index 6cf8e8b3485ff..906ebdebdd06a 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -6,9 +6,8 @@ * Side Public License, v 1. */ -import { flattenHit, IIndexPatternFieldList } from '../../../data/common'; +import { IIndexPatternFieldList } from '../../../data/common'; import { IndexPattern } from '../../../data/common'; -import type { estypes } from '@elastic/elasticsearch'; const fields = [ { @@ -64,23 +63,16 @@ const indexPattern = { id: 'index-pattern-with-timefield-id', title: 'index-pattern-with-timefield', metaFields: ['_index', '_score'], - flattenHit: undefined, - formatHit: jest.fn((hit) => hit._source), fields, getComputedFields: () => ({}), getSourceFiltering: () => ({}), getFieldByName: (name: string) => fields.getByName(name), timeFieldName: 'timestamp', - getFormatterForField: () => ({ convert: () => 'formatted' }), + getFormatterForField: () => ({ convert: (value: unknown) => value }), isTimeNanosBased: () => false, popularizeField: () => {}, } as unknown as IndexPattern; indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; -indexPattern.formatField = (hit: Record, fieldName: string) => { - return fieldName === '_source' - ? hit._source - : flattenHit(hit as unknown as estypes.SearchHit, indexPattern)[fieldName]; -}; export const indexPatternWithTimefieldMock = indexPattern; diff --git a/src/plugins/discover/public/__mocks__/index_patterns.ts b/src/plugins/discover/public/__mocks__/index_patterns.ts index 88447eacc884d..b90338e895623 100644 --- a/src/plugins/discover/public/__mocks__/index_patterns.ts +++ b/src/plugins/discover/public/__mocks__/index_patterns.ts @@ -10,12 +10,14 @@ import { IndexPatternsService } from '../../../data/common'; import { indexPatternMock } from './index_pattern'; export const indexPatternsMock = { - getCache: () => { + getCache: async () => { return [indexPatternMock]; }, - get: (id: string) => { + get: async (id: string) => { if (id === 'the-index-pattern-id') { - return indexPatternMock; + return Promise.resolve(indexPatternMock); + } else if (id === 'invalid-index-pattern-id') { + return Promise.reject('Invald'); } }, updateSavedObject: jest.fn(), diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 8cc5ccf5aa121..6a90ed42417e6 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -13,6 +13,7 @@ import { CONTEXT_STEP_SETTING, DEFAULT_COLUMNS_SETTING, DOC_HIDE_TIME_COLUMN_SETTING, + MAX_DOC_FIELDS_DISPLAYED, SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING, } from '../../common'; @@ -43,9 +44,13 @@ export const discoverServiceMock = { save: true, }, }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, filterManager: dataPlugin.query.filterManager, uiSettings: { - get: (key: string) => { + get: jest.fn((key: string) => { if (key === 'fields:popularLimit') { return 5; } else if (key === DEFAULT_COLUMNS_SETTING) { @@ -62,8 +67,10 @@ export const discoverServiceMock = { return false; } else if (key === SAMPLE_SIZE_SETTING) { return 250; + } else if (key === MAX_DOC_FIELDS_DISPLAYED) { + return 50; } - }, + }), isDefault: (key: string) => { return true; }, diff --git a/src/plugins/discover/public/application/apps/context/context_app.test.tsx b/src/plugins/discover/public/application/apps/context/context_app.test.tsx index 0e50f8f714a2c..d1c557f2839bc 100644 --- a/src/plugins/discover/public/application/apps/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app.test.tsx @@ -62,6 +62,10 @@ describe('ContextApp test', () => { navigation: mockNavigationPlugin, core: { notifications: { toasts: [] } }, history: () => {}, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, filterManager: mockFilterManager, uiSettings: uiSettingsMock, } as unknown as DiscoverServices); diff --git a/src/plugins/discover/public/application/apps/context/context_app_route.tsx b/src/plugins/discover/public/application/apps/context/context_app_route.tsx index d124fd6cfa395..6c4722418be14 100644 --- a/src/plugins/discover/public/application/apps/context/context_app_route.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app_route.tsx @@ -8,6 +8,8 @@ import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DiscoverServices } from '../../../build_services'; import { ContextApp } from './context_app'; import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; @@ -43,7 +45,29 @@ export function ContextAppRoute(props: ContextAppProps) { ]); }, [chrome]); - const indexPattern = useIndexPattern(services.indexPatterns, indexPatternId); + const { indexPattern, error } = useIndexPattern(services.indexPatterns, indexPatternId); + + if (error) { + return ( + + } + body={ + + } + /> + ); + } if (!indexPattern) { return ; diff --git a/src/plugins/discover/public/application/apps/doc/components/doc.test.tsx b/src/plugins/discover/public/application/apps/doc/components/doc.test.tsx index 31ff39ea6b577..68c012ddd92e9 100644 --- a/src/plugins/discover/public/application/apps/doc/components/doc.test.tsx +++ b/src/plugins/discover/public/application/apps/doc/components/doc.test.tsx @@ -14,6 +14,7 @@ import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../../../../common'; +import { indexPatternMock } from '../../../../__mocks__/index_pattern'; const mockSearchApi = jest.fn(); @@ -74,21 +75,11 @@ const waitForPromises = async () => * this works but logs ugly error messages until we're using React 16.9 * should be adapted when we upgrade */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function mountDoc(update = false, indexPatternGetter: any = null) { - const indexPattern = { - getComputedFields: () => [], - }; - const indexPatternService = { - get: indexPatternGetter ? indexPatternGetter : jest.fn(() => Promise.resolve(indexPattern)), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - +async function mountDoc(update = false) { const props = { id: '1', index: 'index1', - indexPatternId: 'xyz', - indexPatternService, + indexPattern: indexPatternMock, } as DocProps; let comp!: ReactWrapper; await act(async () => { @@ -108,12 +99,6 @@ describe('Test of of Discover', () => { expect(findTestSubject(comp, 'doc-msg-loading').length).toBe(1); }); - test('renders IndexPattern notFound msg', async () => { - const indexPatternGetter = jest.fn(() => Promise.reject({ savedObjectId: '007' })); - const comp = await mountDoc(true, indexPatternGetter); - expect(findTestSubject(comp, 'doc-msg-notFoundIndexPattern').length).toBe(1); - }); - test('renders notFound msg', async () => { mockSearchApi.mockImplementation(() => throwError({ status: 404 })); const comp = await mountDoc(true); diff --git a/src/plugins/discover/public/application/apps/doc/components/doc.tsx b/src/plugins/discover/public/application/apps/doc/components/doc.tsx index f33ffe561e490..c6cfad3953e95 100644 --- a/src/plugins/discover/public/application/apps/doc/components/doc.tsx +++ b/src/plugins/discover/public/application/apps/doc/components/doc.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent, EuiPage } from '@elastic/eui'; -import { IndexPatternsContract } from 'src/plugins/data/public'; +import { IndexPattern } from 'src/plugins/data/public'; import { getServices } from '../../../../kibana_services'; import { DocViewer } from '../../../components/doc_viewer/doc_viewer'; import { ElasticRequestState } from '../types'; @@ -25,14 +25,9 @@ export interface DocProps { */ index: string; /** - * IndexPattern ID used to get IndexPattern entity - * that's used for adding additional fields (stored_fields, script_fields, docvalue_fields) + * IndexPattern entity */ - indexPatternId: string; - /** - * IndexPatternService to get a given index pattern by ID - */ - indexPatternService: IndexPatternsContract; + indexPattern: IndexPattern; /** * If set, will always request source, regardless of the global `fieldsFromSource` setting */ @@ -40,7 +35,8 @@ export interface DocProps { } export function Doc(props: DocProps) { - const [reqState, hit, indexPattern] = useEsDocSearch(props); + const { indexPattern } = props; + const [reqState, hit] = useEsDocSearch(props); const indexExistsLink = getServices().docLinks.links.apis.indexExists; return ( @@ -54,7 +50,7 @@ export function Doc(props: DocProps) { } /> diff --git a/src/plugins/discover/public/application/apps/doc/single_doc_route.tsx b/src/plugins/discover/public/application/apps/doc/single_doc_route.tsx index 8398f6255e0f9..aef928d523515 100644 --- a/src/plugins/discover/public/application/apps/doc/single_doc_route.tsx +++ b/src/plugins/discover/public/application/apps/doc/single_doc_route.tsx @@ -7,6 +7,8 @@ */ import React, { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DiscoverServices } from '../../../build_services'; import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { Doc } from './components/doc'; @@ -31,7 +33,7 @@ function useQuery() { export function SingleDocRoute(props: SingleDocRouteProps) { const { services } = props; - const { chrome, timefilter, indexPatterns } = services; + const { chrome, timefilter } = services; const { indexPatternId, index } = useParams(); @@ -52,7 +54,29 @@ export function SingleDocRoute(props: SingleDocRouteProps) { timefilter.disableTimeRangeSelector(); }); - const indexPattern = useIndexPattern(services.indexPatterns, indexPatternId); + const { indexPattern, error } = useIndexPattern(services.indexPatterns, indexPatternId); + + if (error) { + return ( + + } + body={ + + } + /> + ); + } if (!indexPattern) { return ; @@ -60,12 +84,7 @@ export function SingleDocRoute(props: SingleDocRouteProps) { return (
- +
); } diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_row.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_row.tsx index d91735460af08..0bf4a36555d16 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_row.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_row.tsx @@ -10,6 +10,7 @@ import React, { Fragment, useCallback, useMemo, useState } from 'react'; import classNames from 'classnames'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiIcon } from '@elastic/eui'; +import { formatFieldValue } from '../../../../../helpers/format_value'; import { flattenHit } from '../../../../../../../../data/common'; import { DocViewer } from '../../../../../components/doc_viewer/doc_viewer'; import { FilterManager, IndexPattern } from '../../../../../../../../data/public'; @@ -58,7 +59,10 @@ export const TableRow = ({ }); const anchorDocTableRowSubj = row.isAnchor ? ' docTableAnchorRow' : ''; - const flattenedRow = useMemo(() => flattenHit(row, indexPattern), [indexPattern, row]); + const flattenedRow = useMemo( + () => flattenHit(row, indexPattern, { includeIgnoredValues: true }), + [indexPattern, row] + ); const mapping = useMemo(() => indexPattern.fields.getByName, [indexPattern]); // toggle display of the rows details, a full list of the fields from each row @@ -68,13 +72,24 @@ export const TableRow = ({ * Fill an element with the value of a field */ const displayField = (fieldName: string) => { - const formattedField = indexPattern.formatField(row, fieldName); - - // field formatters take care of escaping - // eslint-disable-next-line react/no-danger - const fieldElement = ; + // If we're formatting the _source column, don't use the regular field formatter, + // but our Discover mechanism to format a hit in a better human-readable way. + if (fieldName === '_source') { + return formatRow(row, indexPattern, fieldsToShow); + } + + const formattedField = formatFieldValue( + flattenedRow[fieldName], + row, + indexPattern, + mapping(fieldName) + ); - return
{fieldElement}
; + return ( + // formatFieldValue always returns sanitized HTML + // eslint-disable-next-line react/no-danger +
+ ); }; const inlineFilter = useCallback( (column: string, type: '+' | '-') => { @@ -141,10 +156,9 @@ export const TableRow = ({ ); } else { columns.forEach(function (column: string) { - // when useNewFieldsApi is true, addressing to the fields property is safe - if (useNewFieldsApi && !mapping(column) && !row.fields![column]) { + if (useNewFieldsApi && !mapping(column) && row.fields && !row.fields[column]) { const innerColumns = Object.fromEntries( - Object.entries(row.fields!).filter(([key]) => { + Object.entries(row.fields).filter(([key]) => { return key.indexOf(`${column}.`) === 0; }) ); @@ -161,7 +175,13 @@ export const TableRow = ({ /> ); } else { - const isFilterable = Boolean(mapping(column)?.filterable && filter); + // Check whether the field is defined as filterable in the mapping and does + // NOT have ignored values in it to determine whether we want to allow filtering. + // We should improve this and show a helpful tooltip why the filter buttons are not + // there/disabled when there are ignored values. + const isFilterable = Boolean( + mapping(column)?.filterable && filter && !row._ignored?.includes(column) + ); rowCells.push( { const hit = { _id: 'a', + _index: 'foo', _type: 'doc', _score: 1, _source: { @@ -39,7 +40,7 @@ describe('Row formatter', () => { spec: { id, type, version, timeFieldName, fields: JSON.parse(fields), title }, fieldFormats: fieldFormatsMock, shortDotsEnable: false, - metaFields: [], + metaFields: ['_id', '_type', '_score'], }); }; @@ -47,26 +48,15 @@ describe('Row formatter', () => { const fieldsToShow = indexPattern.fields.getAll().map((fld) => fld.name); - // Realistic response with alphabetical insertion order - const formatHitReturnValue = { - also: 'with \\"quotes\\" or 'single qoutes'', - foo: 'bar', - number: '42', - hello: '<h1>World</h1>', - _id: 'a', - _type: 'doc', - _score: 1, - }; - - const formatHitMock = jest.fn().mockReturnValue(formatHitReturnValue); - beforeEach(() => { - // @ts-expect-error - indexPattern.formatHit = formatHitMock; setServices({ uiSettings: { get: () => 100, }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, } as unknown as DiscoverServices); }); @@ -77,32 +67,32 @@ describe('Row formatter', () => { Array [ Array [ "also", - "with \\\\"quotes\\\\" or 'single qoutes'", + "with \\"quotes\\" or 'single quotes'", ], Array [ "foo", "bar", ], Array [ - "number", - "42", + "hello", + "

World

", ], Array [ - "hello", - "<h1>World</h1>", + "number", + 42, ], Array [ "_id", "a", ], - Array [ - "_type", - "doc", - ], Array [ "_score", 1, ], + Array [ + "_type", + "doc", + ], ] } /> @@ -114,6 +104,10 @@ describe('Row formatter', () => { uiSettings: { get: () => 1, }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, } as unknown as DiscoverServices); expect(formatRow(hit, indexPattern, [])).toMatchInlineSnapshot(` { Array [ Array [ "also", - "with \\\\"quotes\\\\" or 'single qoutes'", + "with \\"quotes\\" or 'single quotes'", + ], + Array [ + "foo", + "bar", + ], + Array [ + "hello", + "

World

", + ], + Array [ + "number", + 42, + ], + Array [ + "_id", + "a", + ], + Array [ + "_score", + 1, + ], + Array [ + "_type", + "doc", ], ] } @@ -130,18 +148,18 @@ describe('Row formatter', () => { }); it('formats document with highlighted fields first', () => { - expect(formatRow({ ...hit, highlight: { number: '42' } }, indexPattern, fieldsToShow)) + expect(formatRow({ ...hit, highlight: { number: ['42'] } }, indexPattern, fieldsToShow)) .toMatchInlineSnapshot(` { ], Array [ "hello", - "<h1>World</h1>", + "

World

", ], Array [ "_id", "a", ], - Array [ - "_type", - "doc", - ], Array [ "_score", 1, ], + Array [ + "_type", + "doc", + ], ] } /> diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/lib/row_formatter.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/lib/row_formatter.tsx index 14cf1839107e7..2702a232f21ef 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/lib/row_formatter.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/lib/row_formatter.tsx @@ -6,15 +6,17 @@ * Side Public License, v 1. */ +import { estypes } from '@elastic/elasticsearch'; import React, { Fragment } from 'react'; import type { IndexPattern } from 'src/plugins/data/common'; import { MAX_DOC_FIELDS_DISPLAYED } from '../../../../../../../common'; import { getServices } from '../../../../../../kibana_services'; +import { formatHit } from '../../../../../helpers/format_hit'; import './row_formatter.scss'; interface Props { - defPairs: Array<[string, unknown]>; + defPairs: Array<[string, string]>; } const TemplateComponent = ({ defPairs }: Props) => { return ( @@ -24,8 +26,8 @@ const TemplateComponent = ({ defPairs }: Props) => {
{pair[0]}:
{' '} ))} @@ -34,30 +36,12 @@ const TemplateComponent = ({ defPairs }: Props) => { }; export const formatRow = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hit: Record, + hit: estypes.SearchHit, indexPattern: IndexPattern, fieldsToShow: string[] ) => { - const highlights = hit?.highlight ?? {}; - // Keys are sorted in the hits object - const formatted = indexPattern.formatHit(hit); - const fields = indexPattern.fields; - const highlightPairs: Array<[string, unknown]> = []; - const sourcePairs: Array<[string, unknown]> = []; - Object.entries(formatted).forEach(([key, val]) => { - const displayKey = fields.getByName ? fields.getByName(key)?.displayName : undefined; - const pairs = highlights[key] ? highlightPairs : sourcePairs; - if (displayKey) { - if (fieldsToShow.includes(displayKey)) { - pairs.push([displayKey, val]); - } - } else { - pairs.push([key, val]); - } - }); - const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); - return ; + const pairs = formatHit(hit, indexPattern, fieldsToShow); + return ; }; export const formatTopLevelObject = ( @@ -68,8 +52,8 @@ export const formatTopLevelObject = ( indexPattern: IndexPattern ) => { const highlights = row.highlight ?? {}; - const highlightPairs: Array<[string, unknown]> = []; - const sourcePairs: Array<[string, unknown]> = []; + const highlightPairs: Array<[string, string]> = []; + const sourcePairs: Array<[string, string]> = []; const sorted = Object.entries(fields).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); sorted.forEach(([key, values]) => { const field = indexPattern.getFieldByName(key); diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx index e5212e877e8ba..60540268dcd7f 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.test.tsx @@ -20,6 +20,11 @@ import { DiscoverDocuments } from './discover_documents'; import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; import { indexPatternMock } from '../../../../../__mocks__/index_pattern'; +jest.mock('../../../../../kibana_services', () => ({ + ...jest.requireActual('../../../../../kibana_services'), + getServices: () => jest.requireActual('../../../../../__mocks__/services').discoverServiceMock, +})); + setHeaderActionMenuMounter(jest.fn()); function getProps(fetchStatus: FetchStatus, hits: ElasticSearchHit[]) { diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx index 6ebed3185e2f1..7e3252dce1ef5 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.test.tsx @@ -33,6 +33,19 @@ import { RequestAdapter } from '../../../../../../../inspector'; import { Chart } from '../chart/point_series'; import { DiscoverSidebar } from '../sidebar/discover_sidebar'; +jest.mock('../../../../../kibana_services', () => ({ + ...jest.requireActual('../../../../../kibana_services'), + getServices: () => ({ + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, + uiSettings: { + get: jest.fn((key: string) => key === 'discover:maxDocFieldsDisplayed' && 50), + }, + }), +})); + setHeaderActionMenuMounter(jest.fn()); function getProps(indexPattern: IndexPattern, wasSidebarClosed?: boolean): DiscoverLayoutProps { diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap b/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap index ebb06e0b2ecd3..02e2879476a5e 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_index_pattern_management.test.tsx.snap @@ -115,42 +115,7 @@ exports[`Discover IndexPattern Management renders correctly 1`] = ` "deserialize": [MockFunction], "getByFieldType": [MockFunction], "getDefaultConfig": [MockFunction], - "getDefaultInstance": [MockFunction] { - "calls": Array [ - Array [ - "string", - ], - Array [ - "string", - ], - Array [ - "string", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Object { - "convert": [MockFunction], - "getConverterFor": [MockFunction], - }, - }, - Object { - "type": "return", - "value": Object { - "convert": [MockFunction], - "getConverterFor": [MockFunction], - }, - }, - Object { - "type": "return", - "value": Object { - "convert": [MockFunction], - "getConverterFor": [MockFunction], - }, - }, - ], - }, + "getDefaultInstance": [MockFunction], "getDefaultInstanceCacheResolver": [MockFunction], "getDefaultInstancePlain": [MockFunction], "getDefaultType": [MockFunction], @@ -651,8 +616,6 @@ exports[`Discover IndexPattern Management renders correctly 1`] = ` }, ], "flattenHit": [Function], - "formatField": [Function], - "formatHit": [Function], "getFieldAttrs": [Function], "getOriginalSavedObjectBody": [Function], "id": "logstash-*", diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/field_calculator.js b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/field_calculator.js index be7e9c616273d..c709f3311105d 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/field_calculator.js +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/field_calculator.js @@ -13,7 +13,7 @@ import { flattenHit } from '../../../../../../../../data/common'; function getFieldValues(hits, field, indexPattern) { const name = field.name; return map(hits, function (hit) { - return flattenHit(hit, indexPattern)[name]; + return flattenHit(hit, indexPattern, { includeIgnoredValues: true })[name]; }); } diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx b/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx index 4b572f6e348b8..808346b53304c 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx @@ -42,7 +42,7 @@ describe('Discover topnav component', () => { const props = getProps(true); const component = shallowWithIntl(); const topMenuConfig = component.props().config.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['options', 'new', 'save', 'open', 'share', 'inspect']); + expect(topMenuConfig).toEqual(['options', 'new', 'open', 'share', 'inspect', 'save']); }); test('generated config of TopNavMenu config is correct when no discover save permissions are assigned', () => { diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts index d31ac6e0f2fea..20c5b9bae332d 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts @@ -53,13 +53,6 @@ test('getTopNavLinks result', () => { "run": [Function], "testId": "discoverNewButton", }, - Object { - "description": "Save Search", - "id": "save", - "label": "Save", - "run": [Function], - "testId": "discoverSaveButton", - }, Object { "description": "Open Saved Search", "id": "open", @@ -81,6 +74,15 @@ test('getTopNavLinks result', () => { "run": [Function], "testId": "openInspectorButton", }, + Object { + "description": "Save Search", + "emphasize": true, + "iconType": "save", + "id": "save", + "label": "Save", + "run": [Function], + "testId": "discoverSaveButton", + }, ] `); }); diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts index 81be662470306..44d2999947f41 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts @@ -76,6 +76,8 @@ export const getTopNavLinks = ({ defaultMessage: 'Save Search', }), testId: 'discoverSaveButton', + iconType: 'save', + emphasize: true, run: () => onSaveSearch({ savedSearch, services, indexPattern, navigateTo, state }), }; @@ -153,9 +155,9 @@ export const getTopNavLinks = ({ return [ ...(services.capabilities.advancedSettings.save ? [options] : []), newSearch, - ...(services.capabilities.discover.save ? [saveSearch] : []), openSearch, shareSearch, inspectSearch, + ...(services.capabilities.discover.save ? [saveSearch] : []), ]; }; diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx index 18766b5df7f33..25b04e12c650a 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx @@ -98,16 +98,19 @@ export async function onSaveSearch({ const onSave = async ({ newTitle, newCopyOnSave, + newDescription, isTitleDuplicateConfirmed, onTitleDuplicate, }: { newTitle: string; newCopyOnSave: boolean; + newDescription: string; isTitleDuplicateConfirmed: boolean; onTitleDuplicate: () => void; }) => { const currentTitle = savedSearch.title; savedSearch.title = newTitle; + savedSearch.description = newDescription; const saveOptions: SaveSavedSearchOptions = { onTitleDuplicate, copyOnSave: newCopyOnSave, @@ -136,14 +139,11 @@ export async function onSaveSearch({ onClose={() => {}} title={savedSearch.title ?? ''} showCopyOnSave={!!savedSearch.id} + description={savedSearch.description} objectType={i18n.translate('discover.localMenu.saveSaveSearchObjectType', { defaultMessage: 'search', })} - description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { - defaultMessage: - 'Save your Discover search so you can use it in visualizations and dashboards', - })} - showDescription={false} + showDescription={true} /> ); showSaveModal(saveModal, services.core.i18n.Context); diff --git a/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts b/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts index 211c4e5c8b069..2198d2f66b6b4 100644 --- a/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts +++ b/src/plugins/discover/public/application/apps/main/utils/calc_field_counts.ts @@ -22,7 +22,7 @@ export function calcFieldCounts( return {}; } for (const hit of rows) { - const fields = Object.keys(flattenHit(hit, indexPattern)); + const fields = Object.keys(flattenHit(hit, indexPattern, { includeIgnoredValues: true })); for (const fieldName of fields) { counts[fieldName] = (counts[fieldName] || 0) + 1; } diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx index b2be40c008200..22284480afc05 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx @@ -19,6 +19,11 @@ import { DiscoverServices } from '../../../build_services'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; import { getDocId } from './discover_grid_document_selection'; +jest.mock('../../../kibana_services', () => ({ + ...jest.requireActual('../../../kibana_services'), + getServices: () => jest.requireActual('../../../__mocks__/services').discoverServiceMock, +})); + function getProps() { const servicesMock = { uiSettings: uiSettingsMock, diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx index 11323080274a9..ca403c813010b 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx @@ -271,7 +271,11 @@ export const DiscoverGrid = ({ getRenderCellValueFn( indexPattern, displayedRows, - displayedRows ? displayedRows.map((hit) => flattenHit(hit, indexPattern)) : [], + displayedRows + ? displayedRows.map((hit) => + flattenHit(hit, indexPattern, { includeIgnoredValues: true }) + ) + : [], useNewFieldsApi, fieldsToShow, services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED) diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index 6556876217953..3fb96ba9e9daa 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -25,6 +25,9 @@ jest.mock('../../../kibana_services', () => ({ uiSettings: { get: jest.fn(), }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => (value ? value : '-') })), + }, }), })); @@ -102,7 +105,7 @@ describe('Discover grid cell rendering', function () { rowsSource, rowsSource.map(flatten), false, - [], + ['extension', 'bytes'], 100 ); const component = shallow( @@ -133,7 +136,7 @@ describe('Discover grid cell rendering', function () { } /> - bytes + bytesDisplayName + + _index + + + + _score + + `); }); @@ -196,7 +221,7 @@ describe('Discover grid cell rendering', function () { rowsFields, rowsFields.map(flatten), true, - [], + ['extension', 'bytes'], 100 ); const component = shallow( @@ -229,7 +254,7 @@ describe('Discover grid cell rendering', function () { } /> - bytes + bytesDisplayName + + _index + + + + _score + + `); }); @@ -251,7 +298,7 @@ describe('Discover grid cell rendering', function () { rowsFields, rowsFields.map(flatten), true, - [], + ['extension', 'bytes'], // this is the number of rendered items 1 ); @@ -284,6 +331,41 @@ describe('Discover grid cell rendering', function () { } } /> + + bytesDisplayName + + + + _index + + + + _score + + `); }); @@ -342,7 +424,7 @@ describe('Discover grid cell rendering', function () { rowsFieldsWithTopLevelObject, rowsFieldsWithTopLevelObject.map(flatten), true, - [], + ['object.value', 'extension', 'bytes'], 100 ); const component = shallow( @@ -368,7 +450,7 @@ describe('Discover grid cell rendering', function () { className="dscDiscoverGrid__descriptionListDescription" dangerouslySetInnerHTML={ Object { - "__html": "formatted", + "__html": "100", } } /> @@ -383,7 +465,7 @@ describe('Discover grid cell rendering', function () { rowsFieldsWithTopLevelObject, rowsFieldsWithTopLevelObject.map(flatten), true, - [], + ['extension', 'bytes', 'object.value'], 100 ); const component = shallow( diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx index a052971580666..4066c13f6391e 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx @@ -22,6 +22,8 @@ import { DiscoverGridContext } from './discover_grid_context'; import { JsonCodeEditor } from '../json_code_editor/json_code_editor'; import { defaultMonacoEditorWidth } from './constants'; import { EsHitRecord } from '../../types'; +import { formatFieldValue } from '../../helpers/format_value'; +import { formatHit } from '../../helpers/format_hit'; export const getRenderCellValueFn = ( @@ -145,39 +147,19 @@ export const getRenderCellValueFn = // eslint-disable-next-line @typescript-eslint/no-explicit-any return ; } - const formatted = indexPattern.formatHit(row); - - // Put the most important fields first - const highlights: Record = (row.highlight as Record) ?? {}; - const highlightPairs: Array<[string, string]> = []; - const sourcePairs: Array<[string, string]> = []; - Object.entries(formatted).forEach(([key, val]) => { - const pairs = highlights[key] ? highlightPairs : sourcePairs; - const displayKey = indexPattern.fields.getByName - ? indexPattern.fields.getByName(key)?.displayName - : undefined; - if (displayKey) { - if (fieldsToShow.includes(displayKey)) { - pairs.push([displayKey, val as string]); - } - } else { - pairs.push([key, val as string]); - } - }); + const pairs = formatHit(row, indexPattern, fieldsToShow); return ( - {[...highlightPairs, ...sourcePairs] - .slice(0, maxDocFieldsDisplayed) - .map(([key, value]) => ( - - {key} - - - ))} + {pairs.map(([key, value]) => ( + + {key} + + + ))} ); } @@ -191,12 +173,13 @@ export const getRenderCellValueFn = return {JSON.stringify(rowFlattened[columnId])}; } - const valueFormatted = indexPattern.formatField(row, columnId); - if (typeof valueFormatted === 'undefined') { - return -; - } return ( - // eslint-disable-next-line react/no-danger - + ); }; diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx index a2434170acdd7..188deba755445 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { DocViewerTab } from './doc_viewer_tab'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; +import { indexPatternMock } from '../../../__mocks__/index_pattern'; describe('DocViewerTab', () => { test('changing columns triggers an update', () => { @@ -21,6 +22,7 @@ describe('DocViewerTab', () => { renderProps: { hit: {} as ElasticSearchHit, columns: ['test'], + indexPattern: indexPatternMock, }, }; @@ -31,6 +33,7 @@ describe('DocViewerTab', () => { renderProps: { hit: {} as ElasticSearchHit, columns: ['test2'], + indexPattern: indexPatternMock, }, }; diff --git a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap index 82d9183f3d394..761263ee861b9 100644 --- a/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap +++ b/src/plugins/discover/public/application/components/source_viewer/__snapshots__/source_viewer.test.tsx.snap @@ -5,7 +5,11 @@ exports[`Source Viewer component renders error state 1`] = ` hasLineNumbers={true} id="1" index="index1" - indexPatternId="xyz" + indexPattern={ + Object { + "getComputedFields": [Function], + } + } intl={ Object { "defaultFormats": Object {}, @@ -264,7 +268,11 @@ exports[`Source Viewer component renders json code editor 1`] = ` hasLineNumbers={true} id="1" index="index1" - indexPatternId="xyz" + indexPattern={ + Object { + "getComputedFields": [Function], + } + } intl={ Object { "defaultFormats": Object {}, @@ -619,7 +627,11 @@ exports[`Source Viewer component renders loading state 1`] = ` hasLineNumbers={true} id="1" index="index1" - indexPatternId="xyz" + indexPattern={ + Object { + "getComputedFields": [Function], + } + } intl={ Object { "defaultFormats": Object {}, diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx index 7895c1025dda9..a98c2de6197d8 100644 --- a/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.test.tsx @@ -43,13 +43,13 @@ const mockIndexPatternService = { })); describe('Source Viewer component', () => { test('renders loading state', () => { - jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [0, null, null, () => {}]); + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [0, null, () => {}]); const comp = mountWithIntl( @@ -60,13 +60,13 @@ describe('Source Viewer component', () => { }); test('renders error state', () => { - jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [3, null, null, () => {}]); + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [3, null, () => {}]); const comp = mountWithIntl( @@ -97,9 +97,7 @@ describe('Source Viewer component', () => { _underscore: 123, }, } as never; - jest - .spyOn(hooks, 'useEsDocSearch') - .mockImplementation(() => [2, mockHit, mockIndexPattern, () => {}]); + jest.spyOn(hooks, 'useEsDocSearch').mockImplementation(() => [2, mockHit, () => {}]); jest.spyOn(useUiSettingHook, 'useUiSetting').mockImplementation(() => { return false; }); @@ -107,7 +105,7 @@ describe('Source Viewer component', () => { diff --git a/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx index 9e37ae8f8bf93..31d4d866df21e 100644 --- a/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx +++ b/src/plugins/discover/public/application/components/source_viewer/source_viewer.tsx @@ -17,11 +17,12 @@ import { getServices } from '../../../kibana_services'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; import { ElasticRequestState } from '../../apps/doc/types'; import { useEsDocSearch } from '../../services/use_es_doc_search'; +import { IndexPattern } from '../../../../../data_views/common'; interface SourceViewerProps { id: string; index: string; - indexPatternId: string; + indexPattern: IndexPattern; hasLineNumbers: boolean; width?: number; } @@ -29,19 +30,17 @@ interface SourceViewerProps { export const SourceViewer = ({ id, index, - indexPatternId, + indexPattern, width, hasLineNumbers, }: SourceViewerProps) => { const [editor, setEditor] = useState(); const [jsonValue, setJsonValue] = useState(''); - const indexPatternService = getServices().data.indexPatterns; const useNewFieldsApi = !getServices().uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); - const [reqState, hit, , requestData] = useEsDocSearch({ + const [reqState, hit, requestData] = useEsDocSearch({ id, index, - indexPatternId, - indexPatternService, + indexPattern, requestSource: useNewFieldsApi, }); @@ -106,11 +105,7 @@ export const SourceViewer = ({ ); - if ( - reqState === ElasticRequestState.Error || - reqState === ElasticRequestState.NotFound || - reqState === ElasticRequestState.NotFoundIndexPattern - ) { + if (reqState === ElasticRequestState.Error || reqState === ElasticRequestState.NotFound) { return errorState; } diff --git a/src/plugins/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index ce914edcec703..e61333cce1166 100644 --- a/src/plugins/discover/public/application/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -27,6 +27,10 @@ import { getServices } from '../../../kibana_services'; } }, }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => value })), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), + }, })); const indexPattern = { @@ -65,8 +69,7 @@ const indexPattern = { ], }, metaFields: ['_index', '_score'], - flattenHit: jest.fn(), - formatHit: jest.fn((hit) => hit._source), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), } as unknown as IndexPattern; indexPattern.fields.getByName = (name: string) => { @@ -359,32 +362,7 @@ describe('DocViewTable at Discover Doc with Fields API', () => { ], }, metaFields: ['_index', '_type', '_score', '_id'], - flattenHit: jest.fn((hit) => { - const result = {} as Record; - Object.keys(hit).forEach((key) => { - if (key !== 'fields') { - result[key] = hit[key]; - } else { - Object.keys(hit.fields).forEach((field) => { - result[field] = hit.fields[field]; - }); - } - }); - return result; - }), - formatHit: jest.fn((hit) => { - const result = {} as Record; - Object.keys(hit).forEach((key) => { - if (key !== 'fields') { - result[key] = hit[key]; - } else { - Object.keys(hit.fields).forEach((field) => { - result[field] = hit.fields[field]; - }); - } - }); - return result; - }), + getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })), } as unknown as IndexPattern; indexPatterneCommerce.fields.getByName = (name: string) => { diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index 7f597d846f88f..78a6d9ddd3237 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -20,12 +20,14 @@ import { } from '../../doc_views/doc_views_types'; import { ACTIONS_COLUMN, MAIN_COLUMNS } from './table_columns'; import { getFieldsToShow } from '../../helpers/get_fields_to_show'; +import { getIgnoredReason, IgnoredReason } from '../../helpers/get_ignored_reason'; +import { formatFieldValue } from '../../helpers/format_value'; export interface DocViewerTableProps { columns?: string[]; filter?: DocViewFilterFn; hit: ElasticSearchHit; - indexPattern?: IndexPattern; + indexPattern: IndexPattern; onAddColumn?: (columnName: string) => void; onRemoveColumn?: (columnName: string) => void; } @@ -46,6 +48,7 @@ export interface FieldRecord { }; value: { formattedValue: string; + ignored?: IgnoredReason; }; } @@ -64,8 +67,6 @@ export const DocViewerTable = ({ [indexPattern?.fields] ); - const formattedHit = useMemo(() => indexPattern?.formatHit(hit, 'html'), [hit, indexPattern]); - const tableColumns = useMemo(() => { return filter ? [ACTIONS_COLUMN, ...MAIN_COLUMNS] : MAIN_COLUMNS; }, [filter]); @@ -96,7 +97,7 @@ export const DocViewerTable = ({ return null; } - const flattened = flattenHit(hit, indexPattern, { source: true }); + const flattened = flattenHit(hit, indexPattern, { source: true, includeIgnoredValues: true }); const fieldsToShow = getFieldsToShow(Object.keys(flattened), indexPattern, showMultiFields); const items: FieldRecord[] = Object.keys(flattened) @@ -115,6 +116,8 @@ export const DocViewerTable = ({ const displayName = fieldMapping?.displayName ?? field; const fieldType = isNestedFieldParent(field, indexPattern) ? 'nested' : fieldMapping?.type; + const ignored = getIgnoredReason(fieldMapping ?? field, hit._ignored); + return { action: { onToggleColumn, @@ -130,7 +133,8 @@ export const DocViewerTable = ({ scripted: Boolean(fieldMapping?.scripted), }, value: { - formattedValue: formattedHit[field], + formattedValue: formatFieldValue(flattened[field], hit, indexPattern, fieldMapping), + ignored, }, }; }); diff --git a/src/plugins/discover/public/application/components/table/table_cell_actions.tsx b/src/plugins/discover/public/application/components/table/table_cell_actions.tsx index 7f2f87e7c296c..e43a17448de2e 100644 --- a/src/plugins/discover/public/application/components/table/table_cell_actions.tsx +++ b/src/plugins/discover/public/application/components/table/table_cell_actions.tsx @@ -21,6 +21,7 @@ interface TableActionsProps { fieldMapping?: IndexPatternField; onFilter: DocViewFilterFn; onToggleColumn: (field: string) => void; + ignoredValue: boolean; } export const TableActions = ({ @@ -30,15 +31,16 @@ export const TableActions = ({ flattenedField, onToggleColumn, onFilter, + ignoredValue, }: TableActionsProps) => { return (
onFilter(fieldMapping, flattenedField, '+')} /> onFilter(fieldMapping, flattenedField, '-')} /> ; +interface IgnoreWarningProps { + reason: IgnoredReason; + rawValue: unknown; +} -export const TableFieldValue = ({ formattedValue, field }: TableFieldValueProps) => { +const IgnoreWarning: React.FC = React.memo(({ rawValue, reason }) => { + const multiValue = Array.isArray(rawValue) && rawValue.length > 1; + + const getToolTipContent = (): string => { + switch (reason) { + case IgnoredReason.IGNORE_ABOVE: + return multiValue + ? i18n.translate('discover.docView.table.ignored.multiAboveTooltip', { + defaultMessage: `One or more values in this field are too long and can't be searched or filtered.`, + }) + : i18n.translate('discover.docView.table.ignored.singleAboveTooltip', { + defaultMessage: `The value in this field is too long and can't be searched or filtered.`, + }); + case IgnoredReason.MALFORMED: + return multiValue + ? i18n.translate('discover.docView.table.ignored.multiMalformedTooltip', { + defaultMessage: `This field has one or more malformed values that can't be searched or filtered.`, + }) + : i18n.translate('discover.docView.table.ignored.singleMalformedTooltip', { + defaultMessage: `The value in this field is malformed and can't be searched or filtered.`, + }); + case IgnoredReason.UNKNOWN: + return multiValue + ? i18n.translate('discover.docView.table.ignored.multiUnknownTooltip', { + defaultMessage: `One or more values in this field were ignored by Elasticsearch and can't be searched or filtered.`, + }) + : i18n.translate('discover.docView.table.ignored.singleUnknownTooltip', { + defaultMessage: `The value in this field was ignored by Elasticsearch and can't be searched or filtered.`, + }); + } + }; + + return ( + + + + + + + + {multiValue + ? i18n.translate('discover.docViews.table.ignored.multiValueLabel', { + defaultMessage: 'Contains ignored values', + }) + : i18n.translate('discover.docViews.table.ignored.singleValueLabel', { + defaultMessage: 'Ignored value', + })} + + + + + ); +}); + +type TableFieldValueProps = Pick & { + formattedValue: FieldRecord['value']['formattedValue']; + rawValue: unknown; + ignoreReason?: IgnoredReason; +}; + +export const TableFieldValue = ({ + formattedValue, + field, + rawValue, + ignoreReason, +}: TableFieldValueProps) => { const [fieldOpen, setFieldOpen] = useState(false); - const value = String(formattedValue); + const value = String(rawValue); const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; const isCollapsed = isCollapsible && !fieldOpen; @@ -32,18 +111,26 @@ export const TableFieldValue = ({ formattedValue, field }: TableFieldValueProps) return ( - {isCollapsible && ( - + {(isCollapsible || ignoreReason) && ( + + {isCollapsible && ( + + + + )} + {ignoreReason && ( + + + + )} + )}
); diff --git a/src/plugins/discover/public/application/components/table/table_columns.tsx b/src/plugins/discover/public/application/components/table/table_columns.tsx index 5bd92fe9166e9..5944f9bede646 100644 --- a/src/plugins/discover/public/application/components/table/table_columns.tsx +++ b/src/plugins/discover/public/application/components/table/table_columns.tsx @@ -31,7 +31,7 @@ export const ACTIONS_COLUMN: EuiBasicTableColumn = { ), render: ( { flattenedField, isActive, onFilter, onToggleColumn }: FieldRecord['action'], - { field: { field, fieldMapping } }: FieldRecord + { field: { field, fieldMapping }, value: { ignored } }: FieldRecord ) => { return ( = { flattenedField={flattenedField} onFilter={onFilter!} onToggleColumn={onToggleColumn} + ignoredValue={!!ignored} /> ); }, @@ -82,8 +83,18 @@ export const MAIN_COLUMNS: Array> = [ ), - render: ({ formattedValue }: FieldRecord['value'], { field: { field } }: FieldRecord) => { - return ; + render: ( + { formattedValue, ignored }: FieldRecord['value'], + { field: { field }, action: { flattenedField } }: FieldRecord + ) => { + return ( + + ); }, }, ]; diff --git a/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx b/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx index 5fe1b4dc33342..de56d733442d6 100644 --- a/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx +++ b/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx @@ -20,7 +20,7 @@ export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props const tooltipContent = disabled ? ( ) : ( ) : ( void; onRemoveColumn?: (columnName: string) => void; } diff --git a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx index 8849806cf5959..89c47559d7b4c 100644 --- a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx @@ -402,6 +402,10 @@ export class SavedSearchEmbeddable return this.inspectorAdapters; } + public getDescription() { + return this.savedSearch.description; + } + public destroy() { super.destroy(); if (this.searchProps) { diff --git a/src/plugins/discover/public/application/helpers/format_hit.test.ts b/src/plugins/discover/public/application/helpers/format_hit.test.ts new file mode 100644 index 0000000000000..2cb46f28dd397 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/format_hit.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { estypes } from '@elastic/elasticsearch'; +import { indexPatternMock as dataViewMock } from '../../__mocks__/index_pattern'; +import { formatHit } from './format_hit'; +import { discoverServiceMock } from '../../__mocks__/services'; +import { MAX_DOC_FIELDS_DISPLAYED } from '../../../common'; + +jest.mock('../../kibana_services', () => ({ + getServices: () => jest.requireActual('../../__mocks__/services').discoverServiceMock, +})); + +describe('formatHit', () => { + let hit: estypes.SearchHit; + beforeEach(() => { + hit = { + _id: '1', + _index: 'logs', + fields: { + message: ['foobar'], + extension: ['png'], + 'object.value': [42, 13], + bytes: [123], + }, + }; + (dataViewMock.getFormatterForField as jest.Mock).mockReturnValue({ + convert: (value: unknown) => `formatted:${value}`, + }); + }); + + afterEach(() => { + (discoverServiceMock.uiSettings.get as jest.Mock).mockReset(); + }); + + it('formats a document as expected', () => { + const formatted = formatHit(hit, dataViewMock, ['message', 'extension', 'object.value']); + expect(formatted).toEqual([ + ['extension', 'formatted:png'], + ['message', 'formatted:foobar'], + ['object.value', 'formatted:42,13'], + ['_index', 'formatted:logs'], + ['_score', undefined], + ]); + }); + + it('orders highlighted fields first', () => { + const formatted = formatHit({ ...hit, highlight: { message: ['%%'] } }, dataViewMock, [ + 'message', + 'extension', + 'object.value', + ]); + expect(formatted.map(([fieldName]) => fieldName)).toEqual([ + 'message', + 'extension', + 'object.value', + '_index', + '_score', + ]); + }); + + it('only limits count of pairs based on advanced setting', () => { + (discoverServiceMock.uiSettings.get as jest.Mock).mockImplementation( + (key) => key === MAX_DOC_FIELDS_DISPLAYED && 2 + ); + const formatted = formatHit(hit, dataViewMock, ['message', 'extension', 'object.value']); + expect(formatted).toEqual([ + ['extension', 'formatted:png'], + ['message', 'formatted:foobar'], + ]); + }); + + it('should not include fields not mentioned in fieldsToShow', () => { + const formatted = formatHit(hit, dataViewMock, ['message', 'object.value']); + expect(formatted).toEqual([ + ['message', 'formatted:foobar'], + ['object.value', 'formatted:42,13'], + ['_index', 'formatted:logs'], + ['_score', undefined], + ]); + }); + + it('should filter fields based on their real name not displayName', () => { + const formatted = formatHit(hit, dataViewMock, ['bytes']); + expect(formatted).toEqual([ + ['bytesDisplayName', 'formatted:123'], + ['_index', 'formatted:logs'], + ['_score', undefined], + ]); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/format_hit.ts b/src/plugins/discover/public/application/helpers/format_hit.ts new file mode 100644 index 0000000000000..3890973a3f3e4 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/format_hit.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { estypes } from '@elastic/elasticsearch'; +import { DataView, flattenHit } from '../../../../data/common'; +import { MAX_DOC_FIELDS_DISPLAYED } from '../../../common'; +import { getServices } from '../../kibana_services'; +import { formatFieldValue } from './format_value'; + +const formattedHitCache = new WeakMap(); + +type FormattedHit = Array<[fieldName: string, formattedValue: string]>; + +/** + * Returns a formatted document in form of key/value pairs of the fields name and a formatted value. + * The value returned in each pair is an HTML string which is safe to be applied to the DOM, since + * it's formatted using field formatters. + * @param hit The hit to format + * @param dataView The corresponding data view + * @param fieldsToShow A list of fields that should be included in the document summary. + */ +export function formatHit( + hit: estypes.SearchHit, + dataView: DataView, + fieldsToShow: string[] +): FormattedHit { + const cached = formattedHitCache.get(hit); + if (cached) { + return cached; + } + + const highlights = hit.highlight ?? {}; + // Flatten the object using the flattenHit implementation we use across Discover for flattening documents. + const flattened = flattenHit(hit, dataView, { includeIgnoredValues: true, source: true }); + + const highlightPairs: Array<[fieldName: string, formattedValue: string]> = []; + const sourcePairs: Array<[fieldName: string, formattedValue: string]> = []; + + // Add each flattened field into the corresponding array for highlighted or other fields, + // depending on whether the original hit had a highlight for it. That way we can later + // put highlighted fields first in the document summary. + Object.entries(flattened).forEach(([key, val]) => { + // Retrieve the (display) name of the fields, if it's a mapped field on the data view + const displayKey = dataView.fields.getByName(key)?.displayName; + const pairs = highlights[key] ? highlightPairs : sourcePairs; + // Format the raw value using the regular field formatters for that field + const formattedValue = formatFieldValue(val, hit, dataView, dataView.fields.getByName(key)); + // If the field was a mapped field, we validate it against the fieldsToShow list, if not + // we always include it into the result. + if (displayKey) { + if (fieldsToShow.includes(key)) { + pairs.push([displayKey, formattedValue]); + } + } else { + pairs.push([key, formattedValue]); + } + }); + const maxEntries = getServices().uiSettings.get(MAX_DOC_FIELDS_DISPLAYED); + const formatted = [...highlightPairs, ...sourcePairs].slice(0, maxEntries); + formattedHitCache.set(hit, formatted); + return formatted; +} diff --git a/src/plugins/discover/public/application/helpers/format_value.test.ts b/src/plugins/discover/public/application/helpers/format_value.test.ts new file mode 100644 index 0000000000000..76d95c08e4a19 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/format_value.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FieldFormat } from '../../../../field_formats/common'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { formatFieldValue } from './format_value'; + +import { getServices } from '../../kibana_services'; + +jest.mock('../../kibana_services', () => { + const services = { + fieldFormats: { + getDefaultInstance: jest.fn( + () => ({ convert: (value: unknown) => value } as FieldFormat) + ), + }, + }; + return { getServices: () => services }; +}); + +const hit = { + _id: '1', + _index: 'index', + fields: { + message: 'foo', + }, +}; + +describe('formatFieldValue', () => { + afterEach(() => { + (indexPatternMock.getFormatterForField as jest.Mock).mockReset(); + (getServices().fieldFormats.getDefaultInstance as jest.Mock).mockReset(); + }); + + it('should call correct fieldFormatter for field', () => { + const formatterForFieldMock = indexPatternMock.getFormatterForField as jest.Mock; + const convertMock = jest.fn((value: unknown) => `formatted:${value}`); + formatterForFieldMock.mockReturnValue({ convert: convertMock }); + const field = indexPatternMock.fields.getByName('message'); + expect(formatFieldValue('foo', hit, indexPatternMock, field)).toBe('formatted:foo'); + expect(indexPatternMock.getFormatterForField).toHaveBeenCalledWith(field); + expect(convertMock).toHaveBeenCalledWith('foo', 'html', { field, hit }); + }); + + it('should call default string formatter if no field specified', () => { + const convertMock = jest.fn((value: unknown) => `formatted:${value}`); + (getServices().fieldFormats.getDefaultInstance as jest.Mock).mockReturnValue({ + convert: convertMock, + }); + expect(formatFieldValue('foo', hit, indexPatternMock)).toBe('formatted:foo'); + expect(getServices().fieldFormats.getDefaultInstance).toHaveBeenCalledWith('string'); + expect(convertMock).toHaveBeenCalledWith('foo', 'html', { field: undefined, hit }); + }); + + it('should call default string formatter if no indexPattern is specified', () => { + const convertMock = jest.fn((value: unknown) => `formatted:${value}`); + (getServices().fieldFormats.getDefaultInstance as jest.Mock).mockReturnValue({ + convert: convertMock, + }); + expect(formatFieldValue('foo', hit)).toBe('formatted:foo'); + expect(getServices().fieldFormats.getDefaultInstance).toHaveBeenCalledWith('string'); + expect(convertMock).toHaveBeenCalledWith('foo', 'html', { field: undefined, hit }); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/format_value.ts b/src/plugins/discover/public/application/helpers/format_value.ts new file mode 100644 index 0000000000000..cc33276790372 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/format_value.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { estypes } from '@elastic/elasticsearch'; +import { DataView, DataViewField, KBN_FIELD_TYPES } from '../../../../data/common'; +import { getServices } from '../../kibana_services'; + +/** + * Formats the value of a specific field using the appropriate field formatter if available + * or the default string field formatter otherwise. + * + * @param value The value to format + * @param hit The actual search hit (required to get highlight information from) + * @param dataView The data view if available + * @param field The field that value was from if available + * @returns An sanitized HTML string, that is safe to be applied via dangerouslySetInnerHTML + */ +export function formatFieldValue( + value: unknown, + hit: estypes.SearchHit, + dataView?: DataView, + field?: DataViewField +): string { + if (!dataView || !field) { + // If either no field is available or no data view, we'll use the default + // string formatter to format that field. + return getServices() + .fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.STRING) + .convert(value, 'html', { hit, field }); + } + + // If we have a data view and field we use that fields field formatter + return dataView.getFormatterForField(field).convert(value, 'html', { hit, field }); +} diff --git a/src/plugins/discover/public/application/helpers/get_ignored_reason.test.ts b/src/plugins/discover/public/application/helpers/get_ignored_reason.test.ts new file mode 100644 index 0000000000000..13632ca5ed901 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_ignored_reason.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getIgnoredReason, IgnoredReason } from './get_ignored_reason'; +import { DataViewField, KBN_FIELD_TYPES } from '../../../../data/common'; + +function field(params: Partial): DataViewField { + return { + name: 'text', + type: 'keyword', + ...params, + } as unknown as DataViewField; +} + +describe('getIgnoredReason', () => { + it('will correctly return undefined when no value was ignored', () => { + expect(getIgnoredReason(field({ name: 'foo' }), undefined)).toBeUndefined(); + expect(getIgnoredReason(field({ name: 'foo' }), ['bar', 'baz'])).toBeUndefined(); + }); + + it('will return UNKNOWN if the field passed in was only a name, and thus no type information is present', () => { + expect(getIgnoredReason('foo', ['foo'])).toBe(IgnoredReason.UNKNOWN); + }); + + it('will return IGNORE_ABOVE for string types', () => { + expect(getIgnoredReason(field({ name: 'foo', type: KBN_FIELD_TYPES.STRING }), ['foo'])).toBe( + IgnoredReason.IGNORE_ABOVE + ); + }); + + // Each type that can have malformed values + [ + KBN_FIELD_TYPES.DATE, + KBN_FIELD_TYPES.IP, + KBN_FIELD_TYPES.GEO_POINT, + KBN_FIELD_TYPES.GEO_SHAPE, + KBN_FIELD_TYPES.NUMBER, + ].forEach((type) => { + it(`will return MALFORMED for ${type} fields`, () => { + expect(getIgnoredReason(field({ name: 'foo', type }), ['foo'])).toBe(IgnoredReason.MALFORMED); + }); + }); + + it('will return unknown reasons if it does not know what the reason was', () => { + expect(getIgnoredReason(field({ name: 'foo', type: 'range' }), ['foo'])).toBe( + IgnoredReason.UNKNOWN + ); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_ignored_reason.ts b/src/plugins/discover/public/application/helpers/get_ignored_reason.ts new file mode 100644 index 0000000000000..4d2fb85bdb2c4 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_ignored_reason.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 { estypes } from '@elastic/elasticsearch'; +import { DataViewField, KBN_FIELD_TYPES } from '../../../../data/common'; + +export enum IgnoredReason { + IGNORE_ABOVE = 'ignore_above', + MALFORMED = 'malformed', + UNKNOWN = 'unknown', +} + +/** + * Returns the reason why a specific field was ignored in the response. + * Will return undefined if the field had no ignored values in it. + * This implementation will make some assumptions based on specific types + * of ignored values can only happen with specific field types in Elasticsearch. + * + * @param field Either the data view field or the string name of it. + * @param ignoredFields The hit._ignored value of the hit to validate. + */ +export function getIgnoredReason( + field: DataViewField | string, + ignoredFields: estypes.SearchHit['_ignored'] +): IgnoredReason | undefined { + const fieldName = typeof field === 'string' ? field : field.name; + if (!ignoredFields?.includes(fieldName)) { + return undefined; + } + + if (typeof field === 'string') { + return IgnoredReason.UNKNOWN; + } + + switch (field.type) { + case KBN_FIELD_TYPES.STRING: + return IgnoredReason.IGNORE_ABOVE; + case KBN_FIELD_TYPES.NUMBER: + case KBN_FIELD_TYPES.DATE: + case KBN_FIELD_TYPES.GEO_POINT: + case KBN_FIELD_TYPES.GEO_SHAPE: + case KBN_FIELD_TYPES.IP: + return IgnoredReason.MALFORMED; + default: + return IgnoredReason.UNKNOWN; + } +} diff --git a/src/plugins/discover/public/application/helpers/use_index_pattern.test.tsx b/src/plugins/discover/public/application/helpers/use_index_pattern.test.tsx index 85282afb6fc37..dfc54d8630742 100644 --- a/src/plugins/discover/public/application/helpers/use_index_pattern.test.tsx +++ b/src/plugins/discover/public/application/helpers/use_index_pattern.test.tsx @@ -8,12 +8,24 @@ import { useIndexPattern } from './use_index_pattern'; import { indexPatternMock } from '../../__mocks__/index_pattern'; import { indexPatternsMock } from '../../__mocks__/index_patterns'; -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; describe('Use Index Pattern', () => { test('returning a valid index pattern', async () => { - const { result } = renderHook(() => useIndexPattern(indexPatternsMock, 'the-index-pattern-id')); - await act(() => Promise.resolve()); - expect(result.current).toBe(indexPatternMock); + const { result, waitForNextUpdate } = renderHook(() => + useIndexPattern(indexPatternsMock, 'the-index-pattern-id') + ); + await waitForNextUpdate(); + expect(result.current.indexPattern).toBe(indexPatternMock); + expect(result.current.error).toBe(undefined); + }); + + test('returning an error', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useIndexPattern(indexPatternsMock, 'invalid-index-pattern-id') + ); + await waitForNextUpdate(); + expect(result.current.indexPattern).toBe(undefined); + expect(result.current.error).toBeTruthy(); }); }); diff --git a/src/plugins/discover/public/application/helpers/use_index_pattern.tsx b/src/plugins/discover/public/application/helpers/use_index_pattern.tsx index f53d131920c5c..374f83cbbfe72 100644 --- a/src/plugins/discover/public/application/helpers/use_index_pattern.tsx +++ b/src/plugins/discover/public/application/helpers/use_index_pattern.tsx @@ -10,13 +10,18 @@ import { IndexPattern, IndexPatternsContract } from '../../../../data/common'; export const useIndexPattern = (indexPatterns: IndexPatternsContract, indexPatternId: string) => { const [indexPattern, setIndexPattern] = useState(undefined); + const [error, setError] = useState(); useEffect(() => { async function loadIndexPattern() { - const ip = await indexPatterns.get(indexPatternId); - setIndexPattern(ip); + try { + const item = await indexPatterns.get(indexPatternId); + setIndexPattern(item); + } catch (e) { + setError(e); + } } loadIndexPattern(); - }); - return indexPattern; + }, [indexPatternId, indexPatterns]); + return { indexPattern, error }; }; diff --git a/src/plugins/discover/public/application/services/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/services/use_es_doc_search.test.tsx index af7d189e62882..ca57b470b471a 100644 --- a/src/plugins/discover/public/application/services/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/application/services/use_es_doc_search.test.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; import { buildSearchBody, useEsDocSearch } from './use_es_doc_search'; import { Observable } from 'rxjs'; import { IndexPattern } from 'src/plugins/data/common'; @@ -175,26 +175,14 @@ describe('Test of helper / hook', () => { const indexPattern = { getComputedFields: () => [], }; - const getMock = jest.fn(() => Promise.resolve(indexPattern)); - const indexPatternService = { - get: getMock, - } as unknown as IndexPattern; const props = { id: '1', index: 'index1', - indexPatternId: 'xyz', - indexPatternService, - } as unknown as DocProps; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let hook: any; - await act(async () => { - hook = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props }); - }); - expect(hook.result.current.slice(0, 3)).toEqual([ - ElasticRequestState.Loading, - null, indexPattern, - ]); - expect(getMock).toHaveBeenCalled(); + } as unknown as DocProps; + + const { result } = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props }); + + expect(result.current.slice(0, 2)).toEqual([ElasticRequestState.Loading, null]); }); }); diff --git a/src/plugins/discover/public/application/services/use_es_doc_search.ts b/src/plugins/discover/public/application/services/use_es_doc_search.ts index a2f0cd6f8442b..16a24ff27292b 100644 --- a/src/plugins/discover/public/application/services/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/services/use_es_doc_search.ts @@ -64,11 +64,9 @@ export function buildSearchBody( export function useEsDocSearch({ id, index, - indexPatternId, - indexPatternService, + indexPattern, requestSource, -}: DocProps): [ElasticRequestState, ElasticSearchHit | null, IndexPattern | null, () => void] { - const [indexPattern, setIndexPattern] = useState(null); +}: DocProps): [ElasticRequestState, ElasticSearchHit | null | null, () => void] { const [status, setStatus] = useState(ElasticRequestState.Loading); const [hit, setHit] = useState(null); const { data, uiSettings } = useMemo(() => getServices(), []); @@ -76,14 +74,11 @@ export function useEsDocSearch({ const requestData = useCallback(async () => { try { - const indexPatternEntity = await indexPatternService.get(indexPatternId); - setIndexPattern(indexPatternEntity); - const { rawResponse } = await data.search .search({ params: { index, - body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi, requestSource)?.body, + body: buildSearchBody(id, indexPattern, useNewFieldsApi, requestSource)?.body, }, }) .toPromise(); @@ -105,11 +100,11 @@ export function useEsDocSearch({ setStatus(ElasticRequestState.Error); } } - }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi, requestSource]); + }, [id, index, indexPattern, data.search, useNewFieldsApi, requestSource]); useEffect(() => { requestData(); }, [requestData]); - return [status, hit, indexPattern, requestData]; + return [status, hit, requestData]; } diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index ab2484abee892..ac16b6b3cc2ba 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -36,6 +36,7 @@ import { KibanaLegacyStart } from '../../kibana_legacy/public'; import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { IndexPatternFieldEditorStart } from '../../index_pattern_field_editor/public'; +import { FieldFormatsStart } from '../../field_formats/public'; import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public'; @@ -49,6 +50,7 @@ export interface DiscoverServices { history: () => History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; + fieldFormats: FieldFormatsStart; indexPatterns: IndexPatternsContract; inspector: InspectorPublicPluginStart; metadata: { branch: string }; @@ -82,6 +84,7 @@ export function buildServices( data: plugins.data, docLinks: core.docLinks, theme: plugins.charts.theme, + fieldFormats: plugins.fieldFormats, filterManager: plugins.data.query.filterManager, history: getHistory, indexPatterns: plugins.data.indexPatterns, diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index d86e5f363630c..e170e61f7ebc5 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -61,6 +61,7 @@ import { IndexPatternFieldEditorStart } from '../../../plugins/index_pattern_fie import { DeferredSpinner } from './shared'; import { ViewSavedSearchAction } from './application/embeddable/view_saved_search_action'; import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; +import { FieldFormatsStart } from '../../field_formats/public'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -180,6 +181,7 @@ export interface DiscoverStartPlugins { navigation: NavigationStart; charts: ChartsPluginStart; data: DataPublicPluginStart; + fieldFormats: FieldFormatsStart; share?: SharePluginStart; kibanaLegacy: KibanaLegacyStart; urlForwarding: UrlForwardingStart; @@ -267,7 +269,7 @@ export class DiscoverPlugin diff --git a/src/plugins/field_formats/common/converters/source.test.ts b/src/plugins/field_formats/common/converters/source.test.ts index 298c93dac8c4e..6f9e96a136d0b 100644 --- a/src/plugins/field_formats/common/converters/source.test.ts +++ b/src/plugins/field_formats/common/converters/source.test.ts @@ -19,7 +19,7 @@ describe('Source Format', () => { convertHtml = source.getConverterFor(HTML_CONTEXT_TYPE) as HtmlContextTypeConvert; }); - test('should use the text content type if a field is not passed', () => { + test('should render stringified object', () => { const hit = { foo: 'bar', number: 42, @@ -27,23 +27,8 @@ describe('Source Format', () => { also: 'with "quotes" or \'single quotes\'', }; - expect(convertHtml(hit)).toBe( - '{"foo":"bar","number":42,"hello":"<h1>World</h1>","also":"with \\"quotes\\" or 'single quotes'"}' - ); - }); - - test('should render a description list if a field is passed', () => { - const hit = { - foo: 'bar', - number: 42, - hello: '

World

', - also: 'with "quotes" or \'single quotes\'', - }; - - expect( - convertHtml(hit, { field: 'field', indexPattern: { formatHit: (h: string) => h }, hit }) - ).toMatchInlineSnapshot( - `"
foo:
bar
number:
42
hello:

World

also:
with \\"quotes\\" or 'single quotes'
"` + expect(convertHtml(hit, { field: 'field', hit })).toMatchInlineSnapshot( + `"{"foo":"bar","number":42,"hello":"<h1>World</h1>","also":"with \\\\"quotes\\\\" or 'single quotes'"}"` ); }); }); diff --git a/src/plugins/field_formats/common/converters/source.tsx b/src/plugins/field_formats/common/converters/source.tsx index 1caffb5bfb9a8..f92027ec07451 100644 --- a/src/plugins/field_formats/common/converters/source.tsx +++ b/src/plugins/field_formats/common/converters/source.tsx @@ -7,33 +7,8 @@ */ import { KBN_FIELD_TYPES } from '@kbn/field-types'; -import React, { Fragment } from 'react'; -import ReactDOM from 'react-dom/server'; -import { escape, keys } from 'lodash'; -import { shortenDottedString } from '../utils'; import { FieldFormat } from '../field_format'; -import { TextContextTypeConvert, HtmlContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; -import { FORMATS_UI_SETTINGS } from '../constants/ui_settings'; - -interface Props { - defPairs: Array<[string, string]>; -} -const TemplateComponent = ({ defPairs }: Props) => { - return ( -
- {defPairs.map((pair, idx) => ( - -
-
{' '} - - ))} -
- ); -}; +import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; /** @public */ export class SourceFormat extends FieldFormat { @@ -42,32 +17,4 @@ export class SourceFormat extends FieldFormat { static fieldType = KBN_FIELD_TYPES._SOURCE; textConvert: TextContextTypeConvert = (value: string) => JSON.stringify(value); - - htmlConvert: HtmlContextTypeConvert = (value: string, options = {}) => { - const { field, hit, indexPattern } = options; - - if (!field) { - const converter = this.getConverterFor('text') as Function; - - return escape(converter(value)); - } - - const highlights: Record = (hit && hit.highlight) || {}; - // TODO: remove index pattern dependency - const formatted = hit ? indexPattern!.formatHit(hit) : {}; - const highlightPairs: Array<[string, string]> = []; - const sourcePairs: Array<[string, string]> = []; - const isShortDots = this.getConfig!(FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE); - - keys(formatted).forEach((key) => { - const pairs = highlights[key] ? highlightPairs : sourcePairs; - const newField = isShortDots ? shortenDottedString(key) : key; - const val = formatted![key]; - pairs.push([newField as string, val]); - }, []); - - return ReactDOM.renderToStaticMarkup( - - ); - }; } diff --git a/src/plugins/field_formats/common/types.ts b/src/plugins/field_formats/common/types.ts index 00f9f5d707e89..6f0efebe389a1 100644 --- a/src/plugins/field_formats/common/types.ts +++ b/src/plugins/field_formats/common/types.ts @@ -17,10 +17,6 @@ export type FieldFormatsContentType = 'html' | 'text'; */ export interface HtmlContextTypeOptions { field?: { name: string }; - // TODO: get rid of indexPattern dep completely - indexPattern?: { - formatHit: (hit: { highlight: Record }) => Record; - }; hit?: { highlight: Record }; } diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap index b6679dd7ba493..f38bdb9ac53f0 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap @@ -21,10 +21,28 @@ exports[`home change home route should render a link to change the default route /> - - - -`; - -exports[`home welcome should show the normal home page if loading fails 1`] = ` -, - } - } - template="empty" -> - - - - - -`; - -exports[`home welcome should show the normal home page if welcome screen is disabled locally 1`] = ` -, + application={ + Object { + "capabilities": Object { + "navLinks": Object { + "integrations": true, + }, + }, + } } - } - template="empty" -> - - - - -`; - -exports[`home welcome should show the welcome screen if enabled, and there are no index patterns defined 1`] = ` - -`; - -exports[`home welcome stores skip welcome setting if skipped 1`] = ` -, - } - } - template="empty" -> - - - ({ getServices: () => ({ getBasePath: () => 'path', @@ -22,6 +24,13 @@ jest.mock('../kibana_services', () => ({ chrome: { setBreadcrumbs: () => {}, }, + application: { + capabilities: { + navLinks: { + integrations: mockHasIntegrationsPermission, + }, + }, + }, }), })); @@ -35,6 +44,7 @@ describe('home', () => { let defaultProps: HomeProps; beforeEach(() => { + mockHasIntegrationsPermission = true; defaultProps = { directories: [], solutions: [], @@ -182,7 +192,7 @@ describe('home', () => { expect(defaultProps.localStorage.getItem).toHaveBeenCalledTimes(1); - expect(component).toMatchSnapshot(); + expect(component.find(Welcome).exists()).toBe(true); }); test('stores skip welcome setting if skipped', async () => { @@ -196,7 +206,7 @@ describe('home', () => { expect(defaultProps.localStorage.setItem).toHaveBeenCalledWith('home:welcome:show', 'false'); - expect(component).toMatchSnapshot(); + expect(component.find(Welcome).exists()).toBe(false); }); test('should show the normal home page if loading fails', async () => { @@ -205,7 +215,7 @@ describe('home', () => { const hasUserIndexPattern = jest.fn(() => Promise.reject('Doh!')); const component = await renderHome({ hasUserIndexPattern }); - expect(component).toMatchSnapshot(); + expect(component.find(Welcome).exists()).toBe(false); }); test('should show the normal home page if welcome screen is disabled locally', async () => { @@ -213,7 +223,15 @@ describe('home', () => { const component = await renderHome(); - expect(component).toMatchSnapshot(); + expect(component.find(Welcome).exists()).toBe(false); + }); + + test("should show the normal home page if user doesn't have access to integrations", async () => { + mockHasIntegrationsPermission = false; + + const component = await renderHome(); + + expect(component.find(Welcome).exists()).toBe(false); }); }); diff --git a/src/plugins/home/public/application/components/home.tsx b/src/plugins/home/public/application/components/home.tsx index d398311d30255..2a08754889c28 100644 --- a/src/plugins/home/public/application/components/home.tsx +++ b/src/plugins/home/public/application/components/home.tsx @@ -45,10 +45,10 @@ export class Home extends Component { constructor(props: HomeProps) { super(props); - const isWelcomeEnabled = !( - getServices().homeConfig.disableWelcomeScreen || - props.localStorage.getItem(KEY_ENABLE_WELCOME) === 'false' - ); + const isWelcomeEnabled = + !getServices().homeConfig.disableWelcomeScreen && + getServices().application.capabilities.navLinks.integrations && + props.localStorage.getItem(KEY_ENABLE_WELCOME) !== 'false'; const body = document.querySelector('body')!; body.classList.add('isHomPage'); diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index b0ba4d46646d0..1dbcaa6f50fa1 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -17,8 +17,11 @@ import { getTutorial } from '../load_tutorials'; import { replaceTemplateStrings } from './tutorial/replace_template_strings'; import { getServices } from '../kibana_services'; +const REDIRECT_TO_INTEGRATIONS_TAB_IDS = ['all', 'logging', 'metrics', 'security']; + export function HomeApp({ directories, solutions }) { const { + application, savedObjectsClient, getBasePath, addBasePath, @@ -30,10 +33,17 @@ export function HomeApp({ directories, solutions }) { const isCloudEnabled = environment.cloud; const renderTutorialDirectory = (props) => { + // Redirect to integrations app unless a specific tab that is still supported was specified. + const tabId = props.match.params.tab; + if (!tabId || REDIRECT_TO_INTEGRATIONS_TAB_IDS.includes(tabId)) { + application.navigateToApp('integrations', { replace: true }); + return null; + } + return ( ); diff --git a/src/plugins/home/public/application/components/manage_data/manage_data.tsx b/src/plugins/home/public/application/components/manage_data/manage_data.tsx index b374bdd2e1612..0f465dfcf965f 100644 --- a/src/plugins/home/public/application/components/manage_data/manage_data.tsx +++ b/src/plugins/home/public/application/components/manage_data/manage_data.tsx @@ -61,7 +61,8 @@ export const ManageData: FC = ({ addBasePath, application, features }) => {isDevToolsEnabled || isManagementEnabled ? ( - {isDevToolsEnabled ? ( + {/* Check if both the Dev Tools UI and the Console UI are enabled. */} + {isDevToolsEnabled && consoleHref !== undefined ? ( { @@ -72,10 +70,9 @@ class TutorialDirectoryUi extends React.Component { getServices().chrome.setBreadcrumbs([ { - text: homeTitle, - href: '#/', + text: integrationsTitle, + href: this.props.addBasePath(`/app/integrations/browse`), }, - { text: addDataTitle }, ]); const tutorialConfigs = await getTutorials(); @@ -155,6 +152,15 @@ class TutorialDirectoryUi extends React.Component { renderTabContent = () => { const tab = this.tabs.find(({ id }) => id === this.state.selectedTabId); if (tab?.content) { + getServices().chrome.setBreadcrumbs([ + { + text: integrationsTitle, + href: this.props.addBasePath(`/app/integrations/browse`), + }, + { + text: tab.name, + }, + ]); return tab.content; } @@ -163,7 +169,7 @@ class TutorialDirectoryUi extends React.Component { {this.state.tutorialCards .filter((tutorial) => { return ( - this.state.selectedTabId === ALL_TAB_ID || + this.state.selectedTabId === SAMPLE_DATA_TAB_ID || this.state.selectedTabId === tutorial.category ); }) diff --git a/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/__snapshots__/empty_index_list_prompt.test.tsx.snap b/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/__snapshots__/empty_index_list_prompt.test.tsx.snap index a104c36e3a8a0..47cad9c7a8216 100644 --- a/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/__snapshots__/empty_index_list_prompt.test.tsx.snap +++ b/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/__snapshots__/empty_index_list_prompt.test.tsx.snap @@ -120,10 +120,12 @@ exports[`EmptyIndexListPrompt should render normally 1`] = `
- + diff --git a/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/empty_index_list_prompt.tsx b/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/empty_index_list_prompt.tsx index d00f9e2368e21..a550209095898 100644 --- a/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/empty_index_list_prompt.tsx +++ b/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/empty_index_list_prompt.tsx @@ -46,14 +46,14 @@ export const EmptyIndexListPrompt = ({ const createAnywayLink = ( createAnyway()} data-test-subj="createAnyway"> ), @@ -153,8 +153,8 @@ export const EmptyIndexListPrompt = ({
- - + + - + = ({ diff --git a/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap b/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap index b9d1b3992778f..87aa20c4617c1 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap @@ -122,7 +122,7 @@ exports[`FieldEditor should render create new scripted field correctly 1`] = ` @@ -365,7 +365,7 @@ exports[`FieldEditor should render edit scripted field correctly 1`] = ` @@ -658,7 +658,7 @@ exports[`FieldEditor should show conflict field warning 1`] = ` @@ -994,7 +994,7 @@ exports[`FieldEditor should show deprecated lang warning 1`] = ` @@ -1343,7 +1343,7 @@ exports[`FieldEditor should show multiple type field warning with a table contai diff --git a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx index 695d02d0744fb..9509f4fb46e0b 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx @@ -506,8 +506,7 @@ export class FieldEditor extends PureComponent } > diff --git a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx index 36f2f8534399f..e46910c170103 100644 --- a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx @@ -39,15 +39,17 @@ export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps const { services } = useKibana(); const navigateToUrl = services.application?.navigateToUrl; - const canShowDevTools = services.application?.capabilities?.dev_tools.show; const devToolsDataUri = compressToEncodedURIComponent(`GET ${indexPattern}/_search\n${json}`); - const devToolsHref = services.share.url.locators + const consoleHref = services.share.url.locators .get('CONSOLE_APP_LOCATOR') ?.useUrl({ loadFrom: `data:text/plain,${devToolsDataUri}` }); + // Check if both the Dev Tools UI and the Console UI are enabled. + const canShowDevTools = + services.application?.capabilities?.dev_tools.show && consoleHref !== undefined; const shouldShowDevToolsLink = !!(indexPattern && canShowDevTools); const handleDevToolsLinkClick = useCallback( - () => devToolsHref && navigateToUrl && navigateToUrl(devToolsHref), - [devToolsHref, navigateToUrl] + () => consoleHref && navigateToUrl && navigateToUrl(consoleHref), + [consoleHref, navigateToUrl] ); return ( @@ -79,7 +81,7 @@ export const RequestCodeViewer = ({ indexPattern, json }: RequestCodeViewerProps size="xs" flush="right" iconType="wrench" - href={devToolsHref} + href={consoleHref} onClick={handleDevToolsLinkClick} data-test-subj="inspectorRequestOpenInConsoleButton" > diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap index 8842a3c9f5842..0554e64c5ecb6 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap @@ -3,6 +3,108 @@ exports[`NoDataPage render 1`] = `
- Button - + - - Add Elastic Agent - - - } -/> +> + + Button + + } + href="/app/integrations/browse" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; exports[`ElasticAgentCard props category 1`] = ` - - Add Elastic Agent - + - +> + Add Elastic Agent - - - } -/> + + } + href="/app/integrations/browse/custom" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; exports[`ElasticAgentCard props href 1`] = ` - - Button - + - - Add Elastic Agent - - - } -/> +> + + Button + + } + href="#" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; exports[`ElasticAgentCard props recommended 1`] = ` - - Add Elastic Agent - + - +> + Add Elastic Agent - - - } -/> + + } + href="/app/integrations/browse" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; exports[`ElasticAgentCard renders 1`] = ` - - Add Elastic Agent - + - +> + Add Elastic Agent - - - } -/> + + } + href="/app/integrations/browse" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } + /> + `; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx index 5a91e568471d1..b9d412fe4df89 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx @@ -12,6 +12,7 @@ import { CoreStart } from 'kibana/public'; import { EuiButton, EuiCard, EuiTextColor, EuiScreenReaderOnly } from '@elastic/eui'; import { useKibana } from '../../../context'; import { NoDataPageActions, NO_DATA_RECOMMENDED } from '../no_data_page'; +import { RedirectAppLinks } from '../../../app_links'; export type ElasticAgentCardProps = NoDataPageActions & { solution: string; @@ -76,23 +77,25 @@ export const ElasticAgentCard: FunctionComponent = ({ ); return ( - - {defaultCTAtitle} - - } - description={i18n.translate('kibana-react.noDataPage.elasticAgentCard.description', { - defaultMessage: `Use Elastic Agent for a simple, unified way to collect data from your machines.`, - })} - betaBadgeLabel={recommended ? NO_DATA_RECOMMENDED : undefined} - footer={footer} - layout={layout as 'vertical' | undefined} - {...cardRest} - /> + + + {defaultCTAtitle} + + } + description={i18n.translate('kibana-react.noDataPage.elasticAgentCard.description', { + defaultMessage: `Use Elastic Agent for a simple, unified way to collect data from your machines.`, + })} + betaBadgeLabel={recommended ? NO_DATA_RECOMMENDED : undefined} + footer={footer} + layout={layout as 'vertical' | undefined} + {...cardRest} + /> + ); }; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx index b2d9ef6ca5008..b8df7b02c1526 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx @@ -17,9 +17,11 @@ import { EuiText, EuiTextColor, EuiLink, + CommonProps, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import classNames from 'classnames'; import { KibanaPageTemplateProps } from '../page_template'; import { ElasticAgentCard, NoDataCard } from './no_data_card'; @@ -63,7 +65,7 @@ export type NoDataPageActions = Partial & { export type NoDataPageActionsProps = Record; -export interface NoDataPageProps { +export interface NoDataPageProps extends CommonProps { /** * Single name for the current solution, used to auto-generate the title, logo, description, and button label */ @@ -94,6 +96,7 @@ export const NoDataPage: FunctionComponent = ({ actions, docsLink, pageTitle, + ...rest }) => { // Convert obj data into an iterable array const entries = Object.entries(actions); @@ -131,7 +134,7 @@ export const NoDataPage: FunctionComponent = ({ }, [actions, sortedData, actionsKeys]); return ( -
+
@@ -109,7 +109,7 @@ exports[`TelemetryManagementSectionComponent renders as expected 1`] = ` />

, - "displayName": "Provide usage statistics", + "displayName": "Provide usage data", "isCustom": true, "isOverridden": false, "name": "telemetry:enabled", diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx index 3686cb10706bf..037603cb165d9 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx @@ -116,14 +116,14 @@ export class TelemetryManagementSection extends Component { setting={{ type: 'boolean', name: 'telemetry:enabled', - displayName: i18n.translate('telemetry.provideUsageStatisticsTitle', { - defaultMessage: 'Provide usage statistics', + displayName: i18n.translate('telemetry.provideUsageDataTitle', { + defaultMessage: 'Provide usage data', }), value: enabled, description: this.renderDescription(), defVal: true, - ariaName: i18n.translate('telemetry.provideUsageStatisticsAriaName', { - defaultMessage: 'Provide usage statistics', + ariaName: i18n.translate('telemetry.provideUsageDataAriaName', { + defaultMessage: 'Provide usage data', }), requiresPageReload: false, category: [], diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select.tsx b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select.tsx index 610b4a91cfd14..8e1880a7a14d8 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/field_select.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import { i18n } from '@kbn/i18n'; -import React, { ReactNode, useContext } from 'react'; +import React, { ReactNode } from 'react'; import { EuiComboBox, EuiComboBoxProps, @@ -20,8 +20,6 @@ import type { TimeseriesUIRestrictions } from '../../../../common/ui_restriction // @ts-ignore import { isFieldEnabled } from '../../lib/check_ui_restrictions'; -import { PanelModelContext } from '../../contexts/panel_model_context'; -import { USE_KIBANA_INDEXES_KEY } from '../../../../common/constants'; interface FieldSelectProps { label: string | ReactNode; @@ -64,7 +62,6 @@ export function FieldSelect({ uiRestrictions, 'data-test-subj': dataTestSubj = 'metricsIndexPatternFieldsSelect', }: FieldSelectProps) { - const panelModel = useContext(PanelModelContext); const htmlId = htmlIdGenerator(); let selectedOptions: Array> = []; @@ -119,18 +116,10 @@ export function FieldSelect({ } }); - let isInvalid; + const isInvalid = Boolean(value && fields[fieldsSelector] && !selectedOptions.length); - if (Boolean(panelModel?.[USE_KIBANA_INDEXES_KEY])) { - isInvalid = Boolean(value && fields[fieldsSelector] && !selectedOptions.length); - - if (value && !selectedOptions.length) { - selectedOptions = [{ label: value!, id: 'INVALID_FIELD' }]; - } - } else { - if (value && fields[fieldsSelector] && !selectedOptions.length) { - onChange([]); - } + if (value && !selectedOptions.length) { + selectedOptions = [{ label: value, id: 'INVALID_FIELD' }]; } return ( diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/external_url_error_modal.tsx b/src/plugins/vis_types/timeseries/public/application/components/lib/external_url_error_modal.tsx new file mode 100644 index 0000000000000..ebb806387d9cf --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/external_url_error_modal.tsx @@ -0,0 +1,60 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiTextColor, +} from '@elastic/eui'; + +interface ExternalUrlErrorModalProps { + url: string; + handleClose: () => void; +} + +export const ExternalUrlErrorModal = ({ url, handleClose }: ExternalUrlErrorModalProps) => ( + + + + + + + + + {url} + + ), + externalUrlPolicy: 'externalUrl.policy', + kibanaConfigFileName: 'kibana.yml', + }} + /> + + + + + + + +); diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js index 7b1db4b362647..b3a48a997b301 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/table/vis.js @@ -17,6 +17,7 @@ import { createFieldFormatter } from '../../lib/create_field_formatter'; import { isSortable } from './is_sortable'; import { EuiToolTip, EuiIcon } from '@elastic/eui'; import { replaceVars } from '../../lib/replace_vars'; +import { ExternalUrlErrorModal } from '../../lib/external_url_error_modal'; import { FIELD_FORMAT_IDS } from '../../../../../../../../plugins/field_formats/common'; import { FormattedMessage } from '@kbn/i18n/react'; import { getFieldFormats, getCoreStart } from '../../../../services'; @@ -53,12 +54,26 @@ class TableVis extends Component { const DateFormat = fieldFormatsService.getType(FIELD_FORMAT_IDS.DATE); this.dateFormatter = new DateFormat({}, this.props.getConfig); + + this.state = { + accessDeniedDrilldownUrl: null, + }; } get visibleSeries() { return get(this.props, 'model.series', []).filter((series) => !series.hidden); } + createDrilldownUrlClickHandler = (url) => (event) => { + const validatedUrl = getCoreStart().http.externalUrl.validateUrl(url); + if (validatedUrl) { + this.setState({ accessDeniedDrilldownUrl: null }); + } else { + event.preventDefault(); + this.setState({ accessDeniedDrilldownUrl: url }); + } + }; + renderRow = (row) => { const { model, fieldFormatMap, getConfig } = this.props; @@ -74,7 +89,16 @@ class TableVis extends Component { if (model.drilldown_url) { const url = replaceVars(model.drilldown_url, {}, { key: row.key }); - rowDisplay = {rowDisplay}; + const handleDrilldownUrlClick = this.createDrilldownUrlClickHandler(url); + rowDisplay = ( + + {rowDisplay} + + ); } const columns = row.series @@ -213,8 +237,11 @@ class TableVis extends Component { ); } + closeExternalUrlErrorModal = () => this.setState({ accessDeniedDrilldownUrl: null }); + render() { const { visData, model } = this.props; + const { accessDeniedDrilldownUrl } = this.state; const header = this.renderHeader(); let rows; @@ -239,16 +266,24 @@ class TableVis extends Component { ); } return ( - - - {header} - {rows} -
-
+ <> + + + {header} + {rows} +
+
+ {accessDeniedDrilldownUrl && ( + + )} + ); } } diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/top_n/vis.js b/src/plugins/vis_types/timeseries/public/application/components/vis_types/top_n/vis.js index 8176f6ece2805..5eb850a753384 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/top_n/vis.js +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/top_n/vis.js @@ -15,10 +15,11 @@ import { getLastValue } from '../../../../../common/last_value_utils'; import { isBackgroundInverted } from '../../../lib/set_is_reversed'; import { replaceVars } from '../../lib/replace_vars'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState, useCallback } from 'react'; import { sortBy, first, get } from 'lodash'; import { DATA_FORMATTERS } from '../../../../../common/enums'; import { getOperator, shouldOperate } from '../../../../../common/operators_utils'; +import { ExternalUrlErrorModal } from '../../lib/external_url_error_modal'; function sortByDirection(data, direction, fn) { if (direction === 'desc') { @@ -41,6 +42,8 @@ function sortSeries(visData, model) { } function TopNVisualization(props) { + const [accessDeniedDrilldownUrl, setAccessDeniedDrilldownUrl] = useState(null); + const coreStart = getCoreStart(); const { backgroundColor, model, visData, fieldFormatMap, getConfig } = props; const series = sortSeries(visData, model).map((item) => { @@ -83,13 +86,27 @@ function TopNVisualization(props) { if (model.drilldown_url) { params.onClick = (item) => { const url = replaceVars(model.drilldown_url, {}, { key: item.label }); - getCoreStart().application.navigateToUrl(url); + const validatedUrl = coreStart.http.externalUrl.validateUrl(url); + if (validatedUrl) { + setAccessDeniedDrilldownUrl(null); + coreStart.application.navigateToUrl(url); + } else { + setAccessDeniedDrilldownUrl(url); + } }; } + const closeExternalUrlErrorModal = useCallback(() => setAccessDeniedDrilldownUrl(null), []); + return (
+ {accessDeniedDrilldownUrl && ( + + )}
); } diff --git a/test/functional/apps/discover/_source_filters.ts b/test/functional/apps/discover/_source_filters.ts index 912ffd8d552b9..134e7cca923b9 100644 --- a/test/functional/apps/discover/_source_filters.ts +++ b/test/functional/apps/discover/_source_filters.ts @@ -14,39 +14,40 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common', 'timePicker', 'discover']); + const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'timePicker', 'discover', 'header', 'settings']); - // FLAKY: https://github.com/elastic/kibana/issues/113130 - describe.skip('source filters', function describeIndexTests() { + describe('source filters', function () { before(async function () { - // delete .kibana index and update configDoc - await kibanaServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - }); - await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/visualize.json'); - - // and load a set of makelogs data await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); - - await kibanaServer.uiSettings.update({ + await kibanaServer.uiSettings.replace({ + defaultIndex: 'logstash-*', 'discover:searchFieldsFromSource': false, }); + log.debug('management'); + await PageObjects.common.navigateToApp('settings'); + await PageObjects.settings.clickKibanaIndexPatterns(); + await PageObjects.settings.clickIndexPatternLogstash(); + await PageObjects.settings.addFieldFilter('referer'); + await PageObjects.settings.addFieldFilter('relatedContent*'); + log.debug('discover'); await PageObjects.common.navigateToApp('discover'); - await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.discover.waitUntilSearchingHasFinished(); - // After hiding the time picker, we need to wait for - // the refresh button to hide before clicking the share button - await PageObjects.common.sleep(1000); + await retry.try(async function () { + expect(await PageObjects.discover.getDocHeader()).to.have.string('Document'); + }); }); after(async () => { await kibanaServer.importExport.unload( 'test/functional/fixtures/kbn_archiver/visualize.json' ); + await kibanaServer.uiSettings.unset('defaultIndex'); }); it('should not get the field referer', async function () { diff --git a/test/functional/apps/home/_add_data.js b/test/functional/apps/home/_add_data.ts similarity index 59% rename from test/functional/apps/home/_add_data.js rename to test/functional/apps/home/_add_data.ts index c69e0a02c26e4..3fd69c1a488f4 100644 --- a/test/functional/apps/home/_add_data.js +++ b/test/functional/apps/home/_add_data.ts @@ -6,20 +6,15 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { - const retry = getService('retry'); +export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'header', 'home', 'dashboard']); describe('add data tutorials', function describeIndexTests() { - it('directory should display registered tutorials', async () => { + it('directory should redirect to integrations app', async () => { await PageObjects.common.navigateToUrl('home', 'tutorial_directory', { useActualUrl: true }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await retry.try(async () => { - const tutorialExists = await PageObjects.home.doesSynopsisExist('netflowlogs'); - expect(tutorialExists).to.be(true); - }); + await PageObjects.common.waitUntilUrlIncludes('/app/integrations'); }); }); } diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 3cf387133bc9c..e0a96940337e2 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -31,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await security.testUser.restoreDefaults(); + await PageObjects.common.unsetTime(); }); it('should display registered flights sample data sets', async () => { @@ -74,6 +75,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard', () => { beforeEach(async () => { + await time(); await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { useActualUrl: true, }); @@ -84,10 +86,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.home.launchSampleDashboard('flights'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const todayYearMonthDay = moment().format('MMM D, YYYY'); - const fromTime = `${todayYearMonthDay} @ 00:00:00.000`; - const toTime = `${todayYearMonthDay} @ 23:59:59.999`; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.be(17); }); @@ -112,10 +110,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.home.launchSampleDashboard('logs'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const todayYearMonthDay = moment().format('MMM D, YYYY'); - const fromTime = `${todayYearMonthDay} @ 00:00:00.000`; - const toTime = `${todayYearMonthDay} @ 23:59:59.999`; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.be(13); }); @@ -124,10 +118,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.home.launchSampleDashboard('ecommerce'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const todayYearMonthDay = moment().format('MMM D, YYYY'); - const fromTime = `${todayYearMonthDay} @ 00:00:00.000`; - const toTime = `${todayYearMonthDay} @ 23:59:59.999`; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.be(15); }); @@ -160,5 +150,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(isInstalled).to.be(false); }); }); + + async function time() { + const today = moment().format('MMM D, YYYY'); + const from = `${today} @ 00:00:00.000`; + const to = `${today} @ 23:59:59.999`; + await PageObjects.common.setTime({ from, to }); + } }); } diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 64fb184f40e48..a40465b00dbeb 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -30,6 +30,7 @@ export class CommonPageObject extends FtrService { private readonly globalNav = this.ctx.getService('globalNav'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly loginPage = this.ctx.getPageObject('login'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); private readonly defaultTryTimeout = this.config.get('timeouts.try'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); @@ -500,4 +501,12 @@ export class CommonPageObject extends FtrService { await this.testSubjects.exists(validator); } } + + async setTime(time: { from: string; to: string }) { + await this.kibanaServer.uiSettings.replace({ 'timepicker:timeDefaults': JSON.stringify(time) }); + } + + async unsetTime() { + await this.kibanaServer.uiSettings.unset('timepicker:timeDefaults'); + } } diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index b929a78072868..633040221ed36 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -558,6 +558,20 @@ export class SettingsPageObject extends FtrService { } } + async addFieldFilter(name: string) { + await this.testSubjects.click('tab-sourceFilters'); + await this.find.setValue('.euiFieldText', name); + await this.find.clickByButtonText('Add'); + const table = await this.find.byClassName('euiTable'); + await this.retry.waitFor('field filter to be added', async () => { + const tableCells = await table.findAllByCssSelector('td'); + const fieldNames = await mapAsync(tableCells, async (cell) => { + return (await cell.getVisibleText()).trim(); + }); + return fieldNames.includes(name); + }); + } + public async confirmSave() { await this.testSubjects.setValue('saveModalConfirmText', 'change'); await this.testSubjects.click('confirmModalConfirmButton'); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 372812d4d0dc1..b51363f1b7006 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -64,7 +64,7 @@ "xpack.observability": "plugins/observability", "xpack.banners": "plugins/banners" }, - "exclude": ["examples", "plugins/monitoring/public/angular/angular_i18n"], + "exclude": ["examples"], "translations": [ "plugins/translations/translations/zh-CN.json", "plugins/translations/translations/ja-JP.json" diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 0d66c9d30f8b9..f838832b6ea66 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -45,9 +45,12 @@ Table of Contents - [`subActionParams (getFields)`](#subactionparams-getfields-1) - [`subActionParams (getIncident)`](#subactionparams-getincident-1) - [`subActionParams (getChoices)`](#subactionparams-getchoices-1) - - [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-) - - [Jira](#jira) + - [ServiceNow ITOM](#servicenow-itom) - [`params`](#params-2) + - [`subActionParams (addEvent)`](#subactionparams-addevent) + - [`subActionParams (getChoices)`](#subactionparams-getchoices-2) + - [Jira](#jira) + - [`params`](#params-3) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [`subActionParams (getIncident)`](#subactionparams-getincident-2) - [`subActionParams (issueTypes)`](#subactionparams-issuetypes) @@ -56,13 +59,13 @@ Table of Contents - [`subActionParams (issue)`](#subactionparams-issue) - [`subActionParams (getFields)`](#subactionparams-getfields-2) - [IBM Resilient](#ibm-resilient) - - [`params`](#params-3) + - [`params`](#params-4) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3) - [`subActionParams (getFields)`](#subactionparams-getfields-3) - [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes) - [`subActionParams (severity)`](#subactionparams-severity) - [Swimlane](#swimlane) - - [`params`](#params-4) + - [`params`](#params-5) - [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -355,6 +358,43 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`. | Property | Description | Type | | -------- | ---------------------------------------------------- | -------- | | fields | An array of fields. Example: `[priority, category]`. | string[] | + +--- +## ServiceNow ITOM + +The [ServiceNow ITOM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-itom-action-type.html) lists configuration properties for the `addEvent` subaction. In addition, several other subaction types are available. +### `params` + +| Property | Description | Type | +| --------------- | ----------------------------------------------------------------- | ------ | +| subAction | The subaction to perform. It can be `addEvent`, and `getChoices`. | string | +| subActionParams | The parameters of the subaction. | object | + +#### `subActionParams (addEvent)` + + +| Property | Description | Type | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| source | The name of the event source type. | string _(optional)_ | +| event_class | Specific instance of the source. | string _(optional)_ | +| resource | The name of the resource. | string _(optional)_ | +| node | The Host that the event was triggered for. | string _(optional)_ | +| metric_name | Name of the metric. | string _(optional)_ | +| type | The type of event. | string _(optional)_ | +| severity | The category in ServiceNow. | string _(optional)_ | +| description | The subcategory in ServiceNow. | string _(optional)_ | +| additional_info | Any additional information about the event. | string _(optional)_ | +| message_key | This value is used for de-duplication of events. All actions sharing this key will be associated with the same ServiceNow alert. | string _(optional)_ | +| time_of_event | The time of the event. | string _(optional)_ | + +Refer to [ServiceNow documentation](https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html) for more information about the properties. + +#### `subActionParams (getChoices)` + +| Property | Description | Type | +| -------- | ------------------------------------------ | -------- | +| fields | An array of fields. Example: `[severity]`. | string[] | + --- ## Jira @@ -418,6 +458,7 @@ No parameters for the `issueTypes` subaction. Provide an empty object `{}`. No parameters for the `getFields` subaction. Provide an empty object `{}`. --- + ## IBM Resilient The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/kibana/master/resilient-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. @@ -545,4 +586,4 @@ Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `sche ## user interface -To make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). +To make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). \ No newline at end of file diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 07859cba4c371..3351a36b38344 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -16,10 +16,15 @@ import { getActionType as getSwimlaneActionType } from './swimlane'; import { getActionType as getServerLogActionType } from './server_log'; import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; -import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; +import { + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getServiceNowITOMActionType, +} from './servicenow'; import { getActionType as getJiraActionType } from './jira'; import { getActionType as getResilientActionType } from './resilient'; import { getActionType as getTeamsActionType } from './teams'; +import { ENABLE_ITOM } from '../constants/connectors'; export { ActionParamsType as EmailActionParams, ActionTypeId as EmailActionTypeId } from './email'; export { ActionParamsType as IndexActionParams, @@ -42,6 +47,7 @@ export { ActionParamsType as ServiceNowActionParams, ServiceNowITSMActionTypeId, ServiceNowSIRActionTypeId, + ServiceNowITOMActionTypeId, } from './servicenow'; export { ActionParamsType as JiraActionParams, ActionTypeId as JiraActionTypeId } from './jira'; export { @@ -75,4 +81,9 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getJiraActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getTeamsActionType({ logger, configurationUtilities })); + + // TODO: Remove when ITOM is ready + if (ENABLE_ITOM) { + actionTypeRegistry.register(getServiceNowITOMActionType({ logger, configurationUtilities })); + } } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index e1f66263729e2..7969f2e53d3d9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -361,6 +361,7 @@ describe('api', () => { const res = await api.getFields({ externalService, params: {}, + logger: mockedLogger, }); expect(res).toEqual(serviceNowCommonFields); }); @@ -371,6 +372,7 @@ describe('api', () => { const res = await api.getChoices({ externalService, params: { fields: ['priority'] }, + logger: mockedLogger, }); expect(res).toEqual(serviceNowChoices); }); @@ -383,6 +385,7 @@ describe('api', () => { params: { externalId: 'incident-1', }, + logger: mockedLogger, }); expect(res).toEqual({ description: 'description from servicenow', diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.test.ts new file mode 100644 index 0000000000000..c918c4a52670a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '../../../../../../src/core/server'; +import { externalServiceITOMMock, itomEventParams } from './mocks'; +import { ExternalServiceITOM } from './types'; +import { apiITOM, prepareParams } from './api_itom'; +let mockedLogger: jest.Mocked; + +describe('api_itom', () => { + let externalService: jest.Mocked; + const eventParamsWithFormattedDate = { + ...itomEventParams, + time_of_event: '2021-10-13, 10:51:44', + }; + + beforeEach(() => { + externalService = externalServiceITOMMock.create(); + jest.clearAllMocks(); + }); + + describe('prepareParams', () => { + test('it prepares the params correctly', async () => { + expect(prepareParams(itomEventParams)).toEqual(eventParamsWithFormattedDate); + }); + + test('it removes null values', async () => { + const { time_of_event: timeOfEvent, ...rest } = itomEventParams; + expect(prepareParams({ ...rest, time_of_event: null })).toEqual(rest); + }); + + test('it set the time to null if it is not a proper date', async () => { + const { time_of_event: timeOfEvent, ...rest } = itomEventParams; + expect(prepareParams({ ...rest, time_of_event: 'not a proper date' })).toEqual(rest); + }); + }); + + describe('addEvent', () => { + test('it adds an event correctly', async () => { + await apiITOM.addEvent({ + externalService, + params: itomEventParams, + logger: mockedLogger, + }); + + expect(externalService.addEvent).toHaveBeenCalledWith(eventParamsWithFormattedDate); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.ts new file mode 100644 index 0000000000000..668e17a042718 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_itom.ts @@ -0,0 +1,70 @@ +/* + * 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 { api } from './api'; +import { + ExecutorSubActionAddEventParams, + AddEventApiHandlerArgs, + ExternalServiceApiITOM, +} from './types'; + +const isValidDate = (d: Date) => !isNaN(d.valueOf()); + +const formatTimeOfEvent = (timeOfEvent: string | null): string | undefined => { + if (timeOfEvent != null) { + const date = new Date(timeOfEvent); + + return isValidDate(date) + ? // The format is: yyyy-MM-dd HH:mm:ss GMT + date.toLocaleDateString('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hour12: false, + minute: '2-digit', + second: '2-digit', + timeZone: 'GMT', + }) + : undefined; + } +}; + +const removeNullValues = ( + params: ExecutorSubActionAddEventParams +): ExecutorSubActionAddEventParams => + (Object.keys(params) as Array).reduce( + (acc, key) => ({ + ...acc, + ...(params[key] != null ? { [key]: params[key] } : {}), + }), + {} as ExecutorSubActionAddEventParams + ); + +export const prepareParams = ( + params: ExecutorSubActionAddEventParams +): ExecutorSubActionAddEventParams => { + const timeOfEvent = formatTimeOfEvent(params.time_of_event); + return removeNullValues({ + ...params, + time_of_event: timeOfEvent ?? null, + }); +}; + +const addEventServiceHandler = async ({ + externalService, + params, +}: AddEventApiHandlerArgs): Promise => { + const itomExternalService = externalService; + const preparedParams = prepareParams(params); + await itomExternalService.addEvent(preparedParams); +}; + +export const apiITOM: ExternalServiceApiITOM = { + getChoices: api.getChoices, + addEvent: addEventServiceHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts index babd360cbcb82..41f723bc9e2aa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts @@ -37,4 +37,15 @@ describe('config', () => { commentFieldKey: 'work_notes', }); }); + + test('ITOM: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow-itom']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'em_event', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts index 37e4c6994b403..52d2eb7662f53 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -6,6 +6,7 @@ */ import { + ENABLE_ITOM, ENABLE_NEW_SN_ITSM_CONNECTOR, ENABLE_NEW_SN_SIR_CONNECTOR, } from '../../constants/connectors'; @@ -16,6 +17,7 @@ export const serviceNowSIRTable = 'sn_si_incident'; export const ServiceNowITSMActionTypeId = '.servicenow'; export const ServiceNowSIRActionTypeId = '.servicenow-sir'; +export const ServiceNowITOMActionTypeId = '.servicenow-itom'; export const snExternalServiceConfig: SNProductsConfig = { '.servicenow': { @@ -32,6 +34,14 @@ export const snExternalServiceConfig: SNProductsConfig = { useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR, commentFieldKey: 'work_notes', }, + '.servicenow-itom': { + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'em_event', + useImportAPI: ENABLE_ITOM, + commentFieldKey: 'work_notes', + }, }; export const FIELD_PREFIX = 'u_'; +export const DEFAULT_ALERTS_GROUPING_KEY = '{{rule.id}}:{{alert.id}}'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts index b342844033994..6ba8b80dfc09c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -8,7 +8,7 @@ import { actionsMock } from '../../mocks'; import { createActionTypeRegistry } from '../index.test'; import { - ServiceNowPublicConfigurationType, + ServiceNowPublicConfigurationBaseType, ServiceNowSecretConfigurationType, ExecutorParams, PushToServiceResponse, @@ -56,7 +56,7 @@ describe('ServiceNow', () => { beforeAll(() => { const { actionTypeRegistry } = createActionTypeRegistry(); actionType = actionTypeRegistry.get< - ServiceNowPublicConfigurationType, + ServiceNowPublicConfigurationBaseType, ServiceNowSecretConfigurationType, ExecutorParams, PushToServiceResponse | {} @@ -91,7 +91,7 @@ describe('ServiceNow', () => { beforeAll(() => { const { actionTypeRegistry } = createActionTypeRegistry(); actionType = actionTypeRegistry.get< - ServiceNowPublicConfigurationType, + ServiceNowPublicConfigurationBaseType, ServiceNowSecretConfigurationType, ExecutorParams, PushToServiceResponse | {} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index 29907381d45da..1e07cf858f332 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -11,9 +11,11 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { validate } from './validators'; import { ExternalIncidentServiceConfiguration, + ExternalIncidentServiceConfigurationBase, ExternalIncidentServiceSecretConfiguration, ExecutorParamsSchemaITSM, ExecutorParamsSchemaSIR, + ExecutorParamsSchemaITOM, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; @@ -32,8 +34,14 @@ import { ExecutorSubActionGetChoicesParams, ServiceFactory, ExternalServiceAPI, + ExecutorParamsITOM, + ExecutorSubActionAddEventParams, + ExternalServiceApiITOM, + ExternalServiceITOM, + ServiceNowPublicConfigurationBaseType, } from './types'; import { + ServiceNowITOMActionTypeId, ServiceNowITSMActionTypeId, serviceNowITSMTable, ServiceNowSIRActionTypeId, @@ -42,12 +50,16 @@ import { } from './config'; import { createExternalServiceSIR } from './service_sir'; import { apiSIR } from './api_sir'; +import { throwIfSubActionIsNotSupported } from './utils'; +import { createExternalServiceITOM } from './service_itom'; +import { apiITOM } from './api_itom'; export { ServiceNowITSMActionTypeId, serviceNowITSMTable, ServiceNowSIRActionTypeId, serviceNowSIRTable, + ServiceNowITOMActionTypeId, }; export type ActionParamsType = @@ -59,21 +71,20 @@ interface GetActionTypeParams { configurationUtilities: ActionsConfigurationUtilities; } -export type ServiceNowActionType = ActionType< - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ExecutorParams, - PushToServiceResponse | {} ->; +export type ServiceNowActionType< + C extends Record = ServiceNowPublicConfigurationBaseType, + T extends Record = ExecutorParams +> = ActionType; -export type ServiceNowActionTypeExecutorOptions = ActionTypeExecutorOptions< - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ExecutorParams ->; +export type ServiceNowActionTypeExecutorOptions< + C extends Record = ServiceNowPublicConfigurationBaseType, + T extends Record = ExecutorParams +> = ActionTypeExecutorOptions; // action type definition -export function getServiceNowITSMActionType(params: GetActionTypeParams): ServiceNowActionType { +export function getServiceNowITSMActionType( + params: GetActionTypeParams +): ServiceNowActionType { const { logger, configurationUtilities } = params; return { id: ServiceNowITSMActionTypeId, @@ -98,7 +109,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic }; } -export function getServiceNowSIRActionType(params: GetActionTypeParams): ServiceNowActionType { +export function getServiceNowSIRActionType( + params: GetActionTypeParams +): ServiceNowActionType { const { logger, configurationUtilities } = params; return { id: ServiceNowSIRActionTypeId, @@ -123,6 +136,33 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service }; } +export function getServiceNowITOMActionType( + params: GetActionTypeParams +): ServiceNowActionType { + const { logger, configurationUtilities } = params; + return { + id: ServiceNowITOMActionTypeId, + minimumLicenseRequired: 'platinum', + name: i18n.SERVICENOW_ITOM, + validate: { + config: schema.object(ExternalIncidentServiceConfigurationBase, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchemaITOM, + }, + executor: curry(executorITOM)({ + logger, + configurationUtilities, + actionTypeId: ServiceNowITOMActionTypeId, + createService: createExternalServiceITOM, + api: apiITOM, + }), + }; +} + // action executor const supportedSubActions: string[] = ['getFields', 'pushToService', 'getChoices', 'getIncident']; async function executor( @@ -139,7 +179,10 @@ async function executor( createService: ServiceFactory; api: ExternalServiceAPI; }, - execOptions: ServiceNowActionTypeExecutorOptions + execOptions: ServiceNowActionTypeExecutorOptions< + ServiceNowPublicConfigurationType, + ExecutorParams + > ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; @@ -156,17 +199,8 @@ async function executor( externalServiceConfig ); - if (!api[subAction]) { - const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`; - logger.error(errorMessage); - throw new Error(errorMessage); - } - - if (!supportedSubActions.includes(subAction)) { - const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`; - logger.error(errorMessage); - throw new Error(errorMessage); - } + const apiAsRecord = api as unknown as Record; + throwIfSubActionIsNotSupported({ api: apiAsRecord, subAction, supportedSubActions, logger }); if (subAction === 'pushToService') { const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; @@ -187,6 +221,7 @@ async function executor( data = await api.getFields({ externalService, params: getFieldsParams, + logger, }); } @@ -195,6 +230,73 @@ async function executor( data = await api.getChoices({ externalService, params: getChoicesParams, + logger, + }); + } + + return { status: 'ok', data: data ?? {}, actionId }; +} + +const supportedSubActionsITOM = ['addEvent', 'getChoices']; + +async function executorITOM( + { + logger, + configurationUtilities, + actionTypeId, + createService, + api, + }: { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + actionTypeId: string; + createService: ServiceFactory; + api: ExternalServiceApiITOM; + }, + execOptions: ServiceNowActionTypeExecutorOptions< + ServiceNowPublicConfigurationBaseType, + ExecutorParamsITOM + > +): Promise> { + const { actionId, config, params, secrets } = execOptions; + const { subAction, subActionParams } = params; + const externalServiceConfig = snExternalServiceConfig[actionTypeId]; + let data: ServiceNowExecutorResultData | null = null; + + const externalService = createService( + { + config, + secrets, + }, + logger, + configurationUtilities, + externalServiceConfig + ) as ExternalServiceITOM; + + const apiAsRecord = api as unknown as Record; + + throwIfSubActionIsNotSupported({ + api: apiAsRecord, + subAction, + supportedSubActions: supportedSubActionsITOM, + logger, + }); + + if (subAction === 'addEvent') { + const eventParams = subActionParams as ExecutorSubActionAddEventParams; + await api.addEvent({ + externalService, + params: eventParams, + logger, + }); + } + + if (subAction === 'getChoices') { + const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams; + data = await api.getChoices({ + externalService, + params: getChoicesParams, + logger, }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 3629fb33915ae..1043fe62af1e1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -12,6 +12,8 @@ import { ExternalServiceSIR, Observable, ObservableTypes, + ExternalServiceITOM, + ExecutorSubActionAddEventParams, } from './types'; export const serviceNowCommonFields = [ @@ -151,6 +153,16 @@ const createSIRMock = (): jest.Mocked => { return service; }; +const createITOMMock = (): jest.Mocked => { + const serviceMock = createMock(); + const service = { + getChoices: serviceMock.getChoices, + addEvent: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + return service; +}; + export const externalServiceMock = { create: createMock, }; @@ -159,6 +171,10 @@ export const externalServiceSIRMock = { create: createSIRMock, }; +export const externalServiceITOMMock = { + create: createITOMMock, +}; + export const executorParams: ExecutorSubActionPushParams = { incident: { externalId: 'incident-3', @@ -227,3 +243,17 @@ export const observables: Observable[] = [ ]; export const apiParams = executorParams; + +export const itomEventParams: ExecutorSubActionAddEventParams = { + source: 'A source', + event_class: 'An event class', + resource: 'C:', + node: 'node.example.com', + metric_name: 'Percentage Logical Disk Free Space', + type: 'Disk space', + severity: '4', + description: 'desc', + additional_info: '{"alert": "test"}', + message_key: 'a key', + time_of_event: '2021-10-13T10:51:44.981Z', +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index dab68bb9d3e9d..5f57555a8f9e1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -6,12 +6,21 @@ */ import { schema } from '@kbn/config-schema'; +import { DEFAULT_ALERTS_GROUPING_KEY } from './config'; -export const ExternalIncidentServiceConfiguration = { +export const ExternalIncidentServiceConfigurationBase = { apiUrl: schema.string(), +}; + +export const ExternalIncidentServiceConfiguration = { + ...ExternalIncidentServiceConfigurationBase, isLegacy: schema.boolean({ defaultValue: false }), }; +export const ExternalIncidentServiceConfigurationBaseSchema = schema.object( + ExternalIncidentServiceConfigurationBase +); + export const ExternalIncidentServiceConfigurationSchema = schema.object( ExternalIncidentServiceConfiguration ); @@ -80,6 +89,21 @@ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ comments: CommentsSchema, }); +// Schema for ServiceNow ITOM +export const ExecutorSubActionAddEventParamsSchema = schema.object({ + source: schema.nullable(schema.string()), + event_class: schema.nullable(schema.string()), + resource: schema.nullable(schema.string()), + node: schema.nullable(schema.string()), + metric_name: schema.nullable(schema.string()), + type: schema.nullable(schema.string()), + severity: schema.nullable(schema.string()), + description: schema.nullable(schema.string()), + additional_info: schema.nullable(schema.string()), + message_key: schema.nullable(schema.string({ defaultValue: DEFAULT_ALERTS_GROUPING_KEY })), + time_of_event: schema.nullable(schema.string()), +}); + export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ externalId: schema.string(), }); @@ -138,3 +162,15 @@ export const ExecutorParamsSchemaSIR = schema.oneOf([ subActionParams: ExecutorSubActionGetChoicesParamsSchema, }), ]); + +// Executor parameters for ITOM +export const ExecutorParamsSchemaITOM = schema.oneOf([ + schema.object({ + subAction: schema.literal('addEvent'), + subActionParams: ExecutorSubActionAddEventParamsSchema, + }), + schema.object({ + subAction: schema.literal('getChoices'), + subActionParams: ExecutorSubActionGetChoicesParamsSchema, + }), +]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts new file mode 100644 index 0000000000000..5223add79d301 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.test.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { createExternalServiceITOM } from './service_itom'; +import * as utils from '../lib/axios_utils'; +import { ExternalServiceITOM } from './types'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { snExternalServiceConfig } from './config'; +import { itomEventParams, serviceNowChoices } from './mocks'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +describe('ServiceNow SIR service', () => { + let service: ExternalServiceITOM; + + beforeEach(() => { + service = createExternalServiceITOM( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-itom'] + ) as ExternalServiceITOM; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('addEvent', () => { + test('it adds an event', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + result: { + 'Default Bulk Endpoint': '1 events were inserted', + }, + }, + })); + + await service.addEvent(itomEventParams); + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/global/em/jsonv2', + method: 'post', + data: { records: [itomEventParams] }, + }); + }); + }); + + describe('getChoices', () => { + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { result: serviceNowChoices }, + })); + await service.getChoices(['severity']); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=em_event^element=severity&sysparm_fields=label,value,dependent_value,element', + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts new file mode 100644 index 0000000000000..aa135e07dbc64 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_itom.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import axios from 'axios'; + +import { + ExternalServiceCredentials, + SNProductsConfigValue, + ServiceFactory, + ExternalServiceITOM, + ExecutorSubActionAddEventParams, +} from './types'; + +import { Logger } from '../../../../../../src/core/server'; +import { ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createExternalService } from './service'; +import { createServiceError } from './utils'; + +const getAddEventURL = (url: string) => `${url}/api/global/em/jsonv2`; + +export const createExternalServiceITOM: ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +): ExternalServiceITOM => { + const snService = createExternalService( + credentials, + logger, + configurationUtilities, + serviceConfig + ); + + const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType; + const axiosInstance = axios.create({ + auth: { username, password }, + }); + + const addEvent = async (params: ExecutorSubActionAddEventParams) => { + try { + const res = await request({ + axios: axiosInstance, + url: getAddEventURL(snService.getUrl()), + logger, + method: 'post', + data: { records: [params] }, + configurationUtilities, + }); + + snService.checkInstance(res); + } catch (error) { + throw createServiceError(error, `Unable to add event`); + } + }; + + return { + addEvent, + getChoices: snService.getChoices, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts index fc8d8cc555bc8..03433f11f9465 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts @@ -29,7 +29,7 @@ const getAddObservableToIncidentURL = (url: string, incidentID: string) => const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) => `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`; -export const createExternalServiceSIR: ServiceFactory = ( +export const createExternalServiceSIR: ServiceFactory = ( credentials: ExternalServiceCredentials, logger: Logger, configurationUtilities: ActionsConfigurationUtilities, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index b46e118a7235f..8b2bb9423d012 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -19,6 +19,10 @@ export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSI defaultMessage: 'ServiceNow SecOps', }); +export const SERVICENOW_ITOM = i18n.translate('xpack.actions.builtin.serviceNowITOMTitle', { + defaultMessage: 'ServiceNow ITOM', +}); + export const ALLOWED_HOSTS_ERROR = (message: string) => i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', { defaultMessage: 'error configuring connector action: {message}', diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index ecca1e55e0fec..31af3781c6b04 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -20,13 +20,21 @@ import { ExecutorParamsSchemaSIR, ExecutorSubActionPushParamsSchemaSIR, ExecutorSubActionGetChoicesParamsSchema, + ExecutorParamsSchemaITOM, + ExecutorSubActionAddEventParamsSchema, + ExternalIncidentServiceConfigurationBaseSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { Logger } from '../../../../../../src/core/server'; +export type ServiceNowPublicConfigurationBaseType = TypeOf< + typeof ExternalIncidentServiceConfigurationBaseSchema +>; + export type ServiceNowPublicConfigurationType = TypeOf< typeof ExternalIncidentServiceConfigurationSchema >; + export type ServiceNowSecretConfigurationType = TypeOf< typeof ExternalIncidentServiceSecretConfigurationSchema >; @@ -108,8 +116,9 @@ export type PushToServiceApiParams = ExecutorSubActionPushParams; export type PushToServiceApiParamsITSM = ExecutorSubActionPushParamsITSM; export type PushToServiceApiParamsSIR = ExecutorSubActionPushParamsSIR; -export interface ExternalServiceApiHandlerArgs { - externalService: ExternalService; +export interface ExternalServiceApiHandlerArgs { + externalService: T; + logger: Logger; } export type ExecutorSubActionGetIncidentParams = TypeOf< @@ -134,7 +143,6 @@ export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerAr params: PushToServiceApiParams; config: Record; secrets: Record; - logger: Logger; commentFieldKey: string; } @@ -162,13 +170,13 @@ export interface ExternalServiceChoices { export type GetCommonFieldsResponse = ExternalServiceFields[]; export type GetChoicesResponse = ExternalServiceChoices[]; -export interface GetCommonFieldsHandlerArgs { - externalService: ExternalService; +export interface GetCommonFieldsHandlerArgs extends ExternalServiceApiHandlerArgs { params: ExecutorSubActionCommonFieldsParams; } export interface GetChoicesHandlerArgs { - externalService: ExternalService; + externalService: Partial & { getChoices: ExternalService['getChoices'] }; + logger: Logger; params: ExecutorSubActionGetChoicesParams; } @@ -276,9 +284,36 @@ export interface ExternalServiceSIR extends ExternalService { ) => Promise; } -export type ServiceFactory = ( +export type ServiceFactory = ( credentials: ExternalServiceCredentials, logger: Logger, configurationUtilities: ActionsConfigurationUtilities, serviceConfig: SNProductsConfigValue -) => ExternalServiceSIR | ExternalService; +) => T; + +/** + * ITOM + */ + +export type ExecutorSubActionAddEventParams = TypeOf; + +export interface ExternalServiceITOM { + getChoices: ExternalService['getChoices']; + addEvent: (params: ExecutorSubActionAddEventParams) => Promise; +} + +export interface AddEventApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionAddEventParams; +} + +export interface GetCommonFieldsHandlerArgsITOM + extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionGetChoicesParams; +} + +export interface ExternalServiceApiITOM { + getChoices: ExternalServiceAPI['getChoices']; + addEvent: (args: AddEventApiHandlerArgs) => Promise; +} + +export type ExecutorParamsITOM = TypeOf; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts index 87f27da6d213f..3eaf5305d5d26 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -6,7 +6,17 @@ */ import { AxiosError } from 'axios'; -import { prepareIncident, createServiceError, getPushedDate } from './utils'; + +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { + prepareIncident, + createServiceError, + getPushedDate, + throwIfSubActionIsNotSupported, +} from './utils'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; /** * The purpose of this test is to @@ -15,7 +25,6 @@ import { prepareIncident, createServiceError, getPushedDate } from './utils'; * such as the scope or the import set table * of our ServiceNow application */ - describe('utils', () => { describe('prepareIncident', () => { test('it prepares the incident correctly when useOldApi=false', async () => { @@ -81,4 +90,45 @@ describe('utils', () => { expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z'); }); }); + + describe('throwIfSubActionIsNotSupported', () => { + const api = { pushToService: 'whatever' }; + + test('it throws correctly if the subAction is not supported', async () => { + expect.assertions(1); + + expect(() => + throwIfSubActionIsNotSupported({ + api, + subAction: 'addEvent', + supportedSubActions: ['getChoices'], + logger, + }) + ).toThrow('[Action][ExternalService] Unsupported subAction type addEvent'); + }); + + test('it throws correctly if the subAction is not implemented', async () => { + expect.assertions(1); + + expect(() => + throwIfSubActionIsNotSupported({ + api, + subAction: 'pushToService', + supportedSubActions: ['getChoices'], + logger, + }) + ).toThrow('[Action][ExternalService] subAction pushToService not implemented.'); + }); + + test('it does not throw if the sub action is supported and implemented', async () => { + expect(() => + throwIfSubActionIsNotSupported({ + api, + subAction: 'pushToService', + supportedSubActions: ['pushToService'], + logger, + }) + ).not.toThrow(); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index 5b7ca99ffc709..3bd4864b71e7a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Logger } from '../../../../../../src/core/server'; import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types'; import { FIELD_PREFIX } from './config'; import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; @@ -44,3 +45,27 @@ export const getPushedDate = (timestamp?: string) => { return new Date().toISOString(); }; + +export const throwIfSubActionIsNotSupported = ({ + api, + subAction, + supportedSubActions, + logger, +}: { + api: Record; + subAction: string; + supportedSubActions: string[]; + logger: Logger; +}) => { + if (!api[subAction]) { + const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (!supportedSubActions.includes(subAction)) { + const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } +}; diff --git a/x-pack/plugins/actions/server/constants/connectors.ts b/x-pack/plugins/actions/server/constants/connectors.ts index f20d499716cf0..94324e4d82bc2 100644 --- a/x-pack/plugins/actions/server/constants/connectors.ts +++ b/x-pack/plugins/actions/server/constants/connectors.ts @@ -10,3 +10,6 @@ export const ENABLE_NEW_SN_ITSM_CONNECTOR = true; // TODO: Remove when Elastic for Security Operations is published. export const ENABLE_NEW_SN_SIR_CONNECTOR = true; + +// TODO: Remove when ready +export const ENABLE_ITOM = true; diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts index 0e6b7fff04451..229f06f2e47fa 100644 --- a/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts +++ b/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts @@ -146,6 +146,7 @@ Object { id: '1', actionTypeId: '.server-log', }, + namespaces: ['default'], }, }, { @@ -154,6 +155,7 @@ Object { id: '2', actionTypeId: '.slack', }, + namespaces: ['default'], }, }, ], @@ -170,6 +172,8 @@ Object { "__server-log": 1, "__slack": 1, }, + "countEmailByService": Object {}, + "countNamespaces": 1, "countTotal": 2, } `); @@ -220,6 +224,7 @@ Object { id: '1', actionTypeId: '.server-log', }, + namespaces: ['default'], }, }, { @@ -228,13 +233,35 @@ Object { id: '2', actionTypeId: '.slack', }, + namespaces: ['default'], }, }, ], }, }) ); - const telemetry = await getInUseTotalCount(mockEsClient, 'test'); + const telemetry = await getInUseTotalCount(mockEsClient, 'test', undefined, [ + { + id: 'test', + actionTypeId: '.email', + name: 'test', + isPreconfigured: true, + config: { + tenantId: 'sdsd', + clientId: 'sdfsdf', + }, + secrets: { + clientSecret: 'sdfsdf', + }, + }, + { + id: 'anotherServerLog', + actionTypeId: '.server-log', + name: 'test', + isPreconfigured: true, + secrets: {}, + }, + ]); expect(mockEsClient.search).toHaveBeenCalledTimes(2); expect(telemetry).toMatchInlineSnapshot(` @@ -245,6 +272,8 @@ Object { "__server-log": 1, "__slack": 1, }, + "countEmailByService": Object {}, + "countNamespaces": 1, "countTotal": 4, } `); @@ -423,6 +452,114 @@ Object { id: '1', actionTypeId: '.server-log', }, + namespaces: ['default'], + }, + }, + { + _source: { + action: { + id: '2', + actionTypeId: '.slack', + }, + namespaces: ['default'], + }, + }, + { + _source: { + action: { + id: '3', + actionTypeId: '.email', + }, + namespaces: ['default'], + }, + }, + ], + }, + }) + ); + const telemetry = await getInUseTotalCount(mockEsClient, 'test', undefined, [ + { + id: 'anotherServerLog', + actionTypeId: '.server-log', + name: 'test', + isPreconfigured: true, + secrets: {}, + }, + ]); + + expect(mockEsClient.search).toHaveBeenCalledTimes(2); + expect(telemetry).toMatchInlineSnapshot(` +Object { + "countByAlertHistoryConnectorType": 1, + "countByType": Object { + "__email": 3, + "__index": 1, + "__server-log": 1, + "__slack": 1, + }, + "countEmailByService": Object { + "other": 3, + }, + "countNamespaces": 1, + "countTotal": 6, +} +`); + }); + + test('getInUseTotalCount() accounts for actions namespaces', async () => { + const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; + mockEsClient.search.mockReturnValueOnce( + // @ts-expect-error not full search response + elasticsearchClientMock.createSuccessTransportRequestPromise({ + aggregations: { + refs: { + actionRefIds: { + value: { + connectorIds: { + '1': 'action-0', + '123': 'action-1', + '456': 'action-2', + }, + total: 3, + }, + }, + }, + preconfigured_actions: { + preconfiguredActionRefIds: { + value: { + total: 3, + actionRefs: { + 'preconfigured:preconfigured-alert-history-es-index': { + actionRef: 'preconfigured:preconfigured-alert-history-es-index', + actionTypeId: '.index', + }, + 'preconfigured:cloud_email': { + actionRef: 'preconfigured:cloud_email', + actionTypeId: '.email', + }, + 'preconfigured:cloud_email2': { + actionRef: 'preconfigured:cloud_email2', + actionTypeId: '.email', + }, + }, + }, + }, + }, + }, + }) + ); + mockEsClient.search.mockReturnValueOnce( + // @ts-expect-error not full search response + elasticsearchClientMock.createSuccessTransportRequestPromise({ + hits: { + hits: [ + { + _source: { + action: { + id: '1', + actionTypeId: '.server-log', + }, + namespaces: ['test'], }, }, { @@ -431,6 +568,7 @@ Object { id: '2', actionTypeId: '.slack', }, + namespaces: ['default'], }, }, { @@ -439,6 +577,7 @@ Object { id: '3', actionTypeId: '.email', }, + namespaces: ['test2'], }, }, ], @@ -457,6 +596,10 @@ Object { "__server-log": 1, "__slack": 1, }, + "countEmailByService": Object { + "other": 1, + }, + "countNamespaces": 3, "countTotal": 6, } `); diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.ts index 4a3d0c70e535a..1cb6bf8bfc74c 100644 --- a/x-pack/plugins/actions/server/usage/actions_telemetry.ts +++ b/x-pack/plugins/actions/server/usage/actions_telemetry.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; import { ElasticsearchClient } from 'kibana/server'; import { AlertHistoryEsIndexConnectorId } from '../../common'; import { ActionResult, PreConfiguredAction } from '../types'; @@ -81,11 +82,15 @@ export async function getTotalCount( export async function getInUseTotalCount( esClient: ElasticsearchClient, - kibanaIndex: string + kibanaIndex: string, + referenceType?: string, + preconfiguredActions?: PreConfiguredAction[] ): Promise<{ countTotal: number; countByType: Record; countByAlertHistoryConnectorType: number; + countEmailByService: Record; + countNamespaces: number; }> { const scriptedMetric = { scripted_metric: { @@ -160,6 +165,63 @@ export async function getInUseTotalCount( }, }; + const mustQuery = [ + { + bool: { + should: [ + { + nested: { + path: 'references', + query: { + bool: { + filter: { + bool: { + must: [ + { + term: { + 'references.type': 'action', + }, + }, + ], + }, + }, + }, + }, + }, + }, + { + nested: { + path: 'alert.actions', + query: { + bool: { + filter: { + bool: { + must: [ + { + prefix: { + 'alert.actions.actionRef': { + value: 'preconfigured:', + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + ], + }, + }, + ] as QueryDslQueryContainer[]; + + if (!!referenceType) { + mustQuery.push({ + term: { type: referenceType }, + }); + } + const { body: actionResults } = await esClient.search({ index: kibanaIndex, body: { @@ -172,54 +234,7 @@ export async function getInUseTotalCount( type: 'action_task_params', }, }, - must: { - bool: { - should: [ - { - nested: { - path: 'references', - query: { - bool: { - filter: { - bool: { - must: [ - { - term: { - 'references.type': 'action', - }, - }, - ], - }, - }, - }, - }, - }, - }, - { - nested: { - path: 'alert.actions', - query: { - bool: { - filter: { - bool: { - must: [ - { - prefix: { - 'alert.actions.actionRef': { - value: 'preconfigured:', - }, - }, - }, - ], - }, - }, - }, - }, - }, - }, - ], - }, - }, + must: mustQuery, }, }, }, @@ -250,13 +265,15 @@ export async function getInUseTotalCount( const preconfiguredActionsAggs = // @ts-expect-error aggegation type is not specified actionResults.aggregations.preconfigured_actions?.preconfiguredActionRefIds.value; + const { body: { hits: actions }, } = await esClient.search<{ action: ActionResult; + namespaces: string[]; }>({ index: kibanaIndex, - _source_includes: ['action'], + _source_includes: ['action', 'namespaces'], body: { query: { bool: { @@ -274,6 +291,7 @@ export async function getInUseTotalCount( }, }, }); + const countByActionTypeId = actions.hits.reduce( (actionTypeCount: Record, action) => { const actionSource = action._source!; @@ -286,6 +304,26 @@ export async function getInUseTotalCount( {} ); + const namespacesList = actions.hits.reduce((_namespaces: Set, action) => { + const namespaces = action._source?.namespaces ?? ['default']; + namespaces.forEach((namespace) => { + if (!_namespaces.has(namespace)) { + _namespaces.add(namespace); + } + }); + return _namespaces; + }, new Set()); + + const countEmailByService = actions.hits + .filter((action) => action._source!.action.actionTypeId === '.email') + .reduce((emailServiceCount: Record, action) => { + const service = (action._source!.action.config?.service ?? 'other') as string; + const currentCount = + emailServiceCount[service] !== undefined ? emailServiceCount[service] : 0; + emailServiceCount[service] = currentCount + 1; + return emailServiceCount; + }, {}); + let preconfiguredAlertHistoryConnectors = 0; const preconfiguredActionsRefs: Array<{ actionTypeId: string; @@ -298,15 +336,40 @@ export async function getInUseTotalCount( if (actionRef === `preconfigured:${AlertHistoryEsIndexConnectorId}`) { preconfiguredAlertHistoryConnectors++; } + if (preconfiguredActions && actionTypeId === '__email') { + const preconfiguredConnectorId = actionRef.split(':')[1]; + const service = (preconfiguredActions.find( + (preconfConnector) => preconfConnector.id === preconfiguredConnectorId + )?.config?.service ?? 'other') as string; + const currentCount = + countEmailByService[service] !== undefined ? countEmailByService[service] : 0; + countEmailByService[service] = currentCount + 1; + } } return { countTotal: aggs.total + (preconfiguredActionsAggs?.total ?? 0), countByType: countByActionTypeId, countByAlertHistoryConnectorType: preconfiguredAlertHistoryConnectors, + countEmailByService, + countNamespaces: namespacesList.size, }; } +export async function getInUseByAlertingTotalCounts( + esClient: ElasticsearchClient, + kibanaIndex: string, + preconfiguredActions?: PreConfiguredAction[] +): Promise<{ + countTotal: number; + countByType: Record; + countByAlertHistoryConnectorType: number; + countEmailByService: Record; + countNamespaces: number; +}> { + return await getInUseTotalCount(esClient, kibanaIndex, 'alert', preconfiguredActions); +} + function replaceFirstAndLastDotSymbols(strToReplace: string) { const hasFirstSymbolDot = strToReplace.startsWith('.'); const appliedString = hasFirstSymbolDot ? strToReplace.replace('.', '__') : strToReplace; diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts index 80e0c19092c78..9ba9d7390a7b6 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts @@ -5,29 +5,12 @@ * 2.0. */ -import { MakeSchemaFrom, UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { get } from 'lodash'; import { TaskManagerStartContract } from '../../../task_manager/server'; -import { ActionsUsage } from './types'; +import { ActionsUsage, byServiceProviderTypeSchema, byTypeSchema } from './types'; import { ActionsConfig } from '../config'; -const byTypeSchema: MakeSchemaFrom['count_by_type'] = { - // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) - DYNAMIC_KEY: { type: 'long' }, - // Known actions: - __email: { type: 'long' }, - __index: { type: 'long' }, - __pagerduty: { type: 'long' }, - __swimlane: { type: 'long' }, - '__server-log': { type: 'long' }, - __slack: { type: 'long' }, - __webhook: { type: 'long' }, - __servicenow: { type: 'long' }, - __jira: { type: 'long' }, - __resilient: { type: 'long' }, - __teams: { type: 'long' }, -}; - export function createActionsUsageCollector( usageCollection: UsageCollectionSetup, config: ActionsConfig, @@ -45,6 +28,7 @@ export function createActionsUsageCollector( _meta: { description: 'Indicates if preconfigured alert history connector is enabled.' }, }, count_total: { type: 'long' }, + count_by_type: byTypeSchema, count_active_total: { type: 'long' }, count_active_alert_history_connectors: { type: 'long', @@ -52,8 +36,9 @@ export function createActionsUsageCollector( description: 'The total number of preconfigured alert history connectors used by rules.', }, }, - count_by_type: byTypeSchema, count_active_by_type: byTypeSchema, + count_active_email_connectors_by_service_type: byServiceProviderTypeSchema, + count_actions_namespaces: { type: 'long' }, }, fetch: async () => { try { @@ -69,10 +54,12 @@ export function createActionsUsageCollector( return { alert_history_connector_enabled: false, count_total: 0, + count_by_type: {}, count_active_total: 0, count_active_alert_history_connectors: 0, count_active_by_type: {}, - count_by_type: {}, + count_active_email_connectors_by_service_type: {}, + count_actions_namespaces: 0, }; } }, diff --git a/x-pack/plugins/actions/server/usage/task.ts b/x-pack/plugins/actions/server/usage/task.ts index 7cbfb87dedda6..bacb9e5f72571 100644 --- a/x-pack/plugins/actions/server/usage/task.ts +++ b/x-pack/plugins/actions/server/usage/task.ts @@ -83,7 +83,7 @@ export function telemetryTaskRunner( const esClient = await getEsClient(); return Promise.all([ getTotalCount(esClient, kibanaIndex, preconfiguredActions), - getInUseTotalCount(esClient, kibanaIndex), + getInUseTotalCount(esClient, kibanaIndex, undefined, preconfiguredActions), ]) .then(([totalAggegations, totalInUse]) => { return { @@ -94,6 +94,8 @@ export function telemetryTaskRunner( count_active_total: totalInUse.countTotal, count_active_by_type: totalInUse.countByType, count_active_alert_history_connectors: totalInUse.countByAlertHistoryConnectorType, + count_active_email_connectors_by_service_type: totalInUse.countEmailByService, + count_actions_namespaces: totalInUse.countNamespaces, }, runAt: getNextMidnight(), }; diff --git a/x-pack/plugins/actions/server/usage/types.ts b/x-pack/plugins/actions/server/usage/types.ts index 9221ba8ea5688..52677b35ac75b 100644 --- a/x-pack/plugins/actions/server/usage/types.ts +++ b/x-pack/plugins/actions/server/usage/types.ts @@ -5,14 +5,47 @@ * 2.0. */ +import { MakeSchemaFrom } from 'src/plugins/usage_collection/server'; + export interface ActionsUsage { alert_history_connector_enabled: boolean; count_total: number; + count_by_type: Record; count_active_total: number; count_active_alert_history_connectors: number; - count_by_type: Record; count_active_by_type: Record; + count_active_email_connectors_by_service_type: Record; + count_actions_namespaces: number; // TODO: Implement executions count telemetry with eventLog, when it will write to index // executions_by_type: Record; // executions_total: number; } + +export const byTypeSchema: MakeSchemaFrom['count_by_type'] = { + // TODO: Find out an automated way to populate the keys or reformat these into an array (and change the Remote Telemetry indexer accordingly) + DYNAMIC_KEY: { type: 'long' }, + // Known actions: + __email: { type: 'long' }, + __index: { type: 'long' }, + __pagerduty: { type: 'long' }, + __swimlane: { type: 'long' }, + '__server-log': { type: 'long' }, + __slack: { type: 'long' }, + __webhook: { type: 'long' }, + __servicenow: { type: 'long' }, + __jira: { type: 'long' }, + __resilient: { type: 'long' }, + __teams: { type: 'long' }, +}; + +export const byServiceProviderTypeSchema: MakeSchemaFrom['count_active_email_connectors_by_service_type'] = + { + DYNAMIC_KEY: { type: 'long' }, + // Known services: + exchange_server: { type: 'long' }, + gmail: { type: 'long' }, + outlook365: { type: 'long' }, + elastic_cloud: { type: 'long' }, + other: { type: 'long' }, + ses: { type: 'long' }, + }; diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts b/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts index cce394d70ed6f..15fa6e63ac561 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts @@ -18,7 +18,14 @@ describe('alerts telemetry', () => { aggregations: { byAlertTypeId: { value: { - types: { '.index-threshold': 2, 'logs.alert.document.count': 1, 'document.test.': 1 }, + ruleTypes: { + '.index-threshold': 2, + 'logs.alert.document.count': 1, + 'document.test.': 1, + }, + namespaces: { + default: 1, + }, }, }, }, @@ -39,6 +46,7 @@ Object { "document.test__": 1, "logs.alert.document.count": 1, }, + "countNamespaces": 1, "countTotal": 4, } `); diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts b/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts index 7d8c1593f533d..18fa9b590b4e1 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts @@ -10,10 +10,14 @@ import { AlertsUsage } from './types'; const alertTypeMetric = { scripted_metric: { - init_script: 'state.types = [:]', + init_script: 'state.ruleTypes = [:]; state.namespaces = [:]', map_script: ` String alertType = doc['alert.alertTypeId'].value; - state.types.put(alertType, state.types.containsKey(alertType) ? state.types.get(alertType) + 1 : 1); + String namespace = doc['namespaces'] !== null ? doc['namespaces'].value : 'default'; + state.ruleTypes.put(alertType, state.ruleTypes.containsKey(alertType) ? state.ruleTypes.get(alertType) + 1 : 1); + if (state.namespaces.containsKey(namespace) === false) { + state.namespaces.put(namespace, 1); + } `, // Combine script is executed per cluster, but we already have a key-value pair per cluster. // Despite docs that say this is optional, this script can't be blank. @@ -40,7 +44,12 @@ export async function getTotalCountAggregations( ): Promise< Pick< AlertsUsage, - 'count_total' | 'count_by_type' | 'throttle_time' | 'schedule_time' | 'connectors_per_alert' + | 'count_total' + | 'count_by_type' + | 'throttle_time' + | 'schedule_time' + | 'connectors_per_alert' + | 'count_rules_namespaces' > > { const throttleTimeMetric = { @@ -247,7 +256,7 @@ export async function getTotalCountAggregations( }); const aggregations = results.aggregations as { - byAlertTypeId: { value: { types: Record } }; + byAlertTypeId: { value: { ruleTypes: Record } }; throttleTime: { value: { min: number; max: number; totalCount: number; totalSum: number } }; intervalTime: { value: { min: number; max: number; totalCount: number; totalSum: number } }; connectorsAgg: { @@ -257,20 +266,20 @@ export async function getTotalCountAggregations( }; }; - const totalAlertsCount = Object.keys(aggregations.byAlertTypeId.value.types).reduce( + const totalAlertsCount = Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( (total: number, key: string) => - parseInt(aggregations.byAlertTypeId.value.types[key], 10) + total, + parseInt(aggregations.byAlertTypeId.value.ruleTypes[key], 10) + total, 0 ); return { count_total: totalAlertsCount, - count_by_type: Object.keys(aggregations.byAlertTypeId.value.types).reduce( + count_by_type: Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( // ES DSL aggregations are returned as `any` by esClient.search // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [replaceFirstAndLastDotSymbols(key)]: aggregations.byAlertTypeId.value.types[key], + [replaceFirstAndLastDotSymbols(key)]: aggregations.byAlertTypeId.value.ruleTypes[key], }), {} ), @@ -300,6 +309,7 @@ export async function getTotalCountAggregations( : 0, max: aggregations.connectorsAgg.connectors.value.max, }, + count_rules_namespaces: 0, }; } @@ -319,24 +329,27 @@ export async function getTotalCountInUse(esClient: ElasticsearchClient, kibanaIn }); const aggregations = searchResult.aggregations as { - byAlertTypeId: { value: { types: Record } }; + byAlertTypeId: { + value: { ruleTypes: Record; namespaces: Record }; + }; }; return { - countTotal: Object.keys(aggregations.byAlertTypeId.value.types).reduce( + countTotal: Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( (total: number, key: string) => - parseInt(aggregations.byAlertTypeId.value.types[key], 10) + total, + parseInt(aggregations.byAlertTypeId.value.ruleTypes[key], 10) + total, 0 ), - countByType: Object.keys(aggregations.byAlertTypeId.value.types).reduce( + countByType: Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( // ES DSL aggregations are returned as `any` by esClient.search // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [replaceFirstAndLastDotSymbols(key)]: aggregations.byAlertTypeId.value.types[key], + [replaceFirstAndLastDotSymbols(key)]: aggregations.byAlertTypeId.value.ruleTypes[key], }), {} ), + countNamespaces: Object.keys(aggregations.byAlertTypeId.value.namespaces).length, }; } diff --git a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts b/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts index 453a29b5884e6..ecea721dfad92 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts @@ -91,6 +91,7 @@ export function createAlertsUsageCollector( }, count_active_by_type: {}, count_by_type: {}, + count_rules_namespaces: 0, }; } }, @@ -115,6 +116,7 @@ export function createAlertsUsageCollector( }, count_active_by_type: byTypeSchema, count_by_type: byTypeSchema, + count_rules_namespaces: { type: 'long' }, }, }); } diff --git a/x-pack/plugins/alerting/server/usage/task.ts b/x-pack/plugins/alerting/server/usage/task.ts index 043d970ddd231..9d39b3765cb5d 100644 --- a/x-pack/plugins/alerting/server/usage/task.ts +++ b/x-pack/plugins/alerting/server/usage/task.ts @@ -89,6 +89,7 @@ export function telemetryTaskRunner(logger: Logger, core: CoreSetup, kibanaIndex count_active_by_type: totalInUse.countByType, count_active_total: totalInUse.countTotal, count_disabled_total: totalCountAggregations.count_total - totalInUse.countTotal, + count_rules_namespaces: totalInUse.countNamespaces, }, runAt: getNextMidnight(), }; diff --git a/x-pack/plugins/alerting/server/usage/types.ts b/x-pack/plugins/alerting/server/usage/types.ts index c3c750da73a7f..5e420b54e37cb 100644 --- a/x-pack/plugins/alerting/server/usage/types.ts +++ b/x-pack/plugins/alerting/server/usage/types.ts @@ -11,6 +11,7 @@ export interface AlertsUsage { count_disabled_total: number; count_by_type: Record; count_active_by_type: Record; + count_rules_namespaces: number; throttle_time: { min: string; avg: string; diff --git a/x-pack/plugins/apm/public/components/app/TraceLink/get_redirect_to_transaction_detail_page_url.test.ts b/x-pack/plugins/apm/public/components/app/TraceLink/get_redirect_to_transaction_detail_page_url.test.ts index 21b2f487fba91..56a4facd20496 100644 --- a/x-pack/plugins/apm/public/components/app/TraceLink/get_redirect_to_transaction_detail_page_url.test.ts +++ b/x-pack/plugins/apm/public/components/app/TraceLink/get_redirect_to_transaction_detail_page_url.test.ts @@ -21,19 +21,35 @@ describe('getRedirectToTransactionDetailPageUrl', () => { }, } as unknown as any; - const url = getRedirectToTransactionDetailPageUrl({ transaction }); + describe('without time range', () => { + const url = getRedirectToTransactionDetailPageUrl({ transaction }); - it('rounds the start time down', () => { - expect(parse(url, true).query.rangeFrom).toBe('2020-01-01T00:00:00.000Z'); - }); + it('rounds the start time down', () => { + expect(parse(url, true).query.rangeFrom).toBe('2020-01-01T00:00:00.000Z'); + }); + + it('rounds the end time up', () => { + expect(parse(url, true).query.rangeTo).toBe('2020-01-01T00:05:00.000Z'); + }); - it('rounds the end time up', () => { - expect(parse(url, true).query.rangeTo).toBe('2020-01-01T00:05:00.000Z'); + it('formats url correctly', () => { + expect(url).toBe( + '/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A00%3A00.000Z&rangeTo=2020-01-01T00%3A05%3A00.000Z' + ); + }); }); - it('formats url correctly', () => { - expect(url).toBe( - '/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A00%3A00.000Z&rangeTo=2020-01-01T00%3A05%3A00.000Z' - ); + describe('with time range', () => { + const url = getRedirectToTransactionDetailPageUrl({ + transaction, + rangeFrom: '2020-01-01T00:02:00.000Z', + rangeTo: '2020-01-01T00:17:59.999Z', + }); + + it('uses timerange provided', () => { + expect(url).toBe( + '/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A02%3A00.000Z&rangeTo=2020-01-01T00%3A17%3A59.999Z' + ); + }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx b/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx index adf1805e0b9ae..eda3b64c309cc 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx @@ -22,6 +22,7 @@ interface CorrelationsTableProps { significantTerms?: T[]; status: FETCH_STATUS; percentageColumnName?: string; + setPinnedSignificantTerm?: (term: T | null) => void; setSelectedSignificantTerm: (term: T | null) => void; selectedTerm?: FieldValuePair; onFilter?: () => void; @@ -33,6 +34,7 @@ interface CorrelationsTableProps { export function CorrelationsTable({ significantTerms, status, + setPinnedSignificantTerm, setSelectedSignificantTerm, columns, selectedTerm, @@ -91,6 +93,11 @@ export function CorrelationsTable({ columns={columns} rowProps={(term) => { return { + onClick: () => { + if (setPinnedSignificantTerm) { + setPinnedSignificantTerm(term); + } + }, onMouseEnter: () => { setSelectedSignificantTerm(term); trackSelectSignificantCorrelationTerm(); diff --git a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx index a177733b3ecaf..838671cbae7d9 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -18,6 +18,7 @@ import { EuiTitle, EuiBetaBadge, EuiBadge, + EuiText, EuiToolTip, EuiSwitch, EuiIconTip, @@ -26,6 +27,8 @@ import type { EuiTableSortingType } from '@elastic/eui/src/components/basic_tabl import type { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { enableInspectEsQueries, useUiTracker, @@ -37,10 +40,13 @@ import { APM_SEARCH_STRATEGIES, DEFAULT_PERCENTILE_THRESHOLD, } from '../../../../common/search_strategies/constants'; +import { FieldStats } from '../../../../common/search_strategies/field_stats_types'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useSearchStrategy } from '../../../hooks/use_search_strategy'; +import { useTheme } from '../../../hooks/use_theme'; import { ImpactBar } from '../../shared/ImpactBar'; import { push } from '../../shared/Links/url_helpers'; @@ -58,10 +64,8 @@ import { CorrelationsLog } from './correlations_log'; import { CorrelationsEmptyStatePrompt } from './empty_state_prompt'; import { CrossClusterSearchCompatibilityWarning } from './cross_cluster_search_warning'; import { CorrelationsProgressControls } from './progress_controls'; -import { useLocalStorage } from '../../../hooks/useLocalStorage'; -import { useTheme } from '../../../hooks/use_theme'; +import { useTransactionColors } from './use_transaction_colors'; import { CorrelationsContextPopover } from './context_popover'; -import { FieldStats } from '../../../../common/search_strategies/field_stats_types'; import { OnAddFilter } from './context_popover/top_values'; export function FailedTransactionsCorrelations({ @@ -69,6 +73,9 @@ export function FailedTransactionsCorrelations({ }: { onFilter: () => void; }) { + const euiTheme = useTheme(); + const transactionColors = useTransactionColors(); + const { core: { notifications, uiSettings }, } = useApmPluginContext(); @@ -96,18 +103,11 @@ export function FailedTransactionsCorrelations({ progress.isRunning ); - const [selectedSignificantTerm, setSelectedSignificantTerm] = - useState(null); - - const selectedTerm = - selectedSignificantTerm ?? response.failedTransactionsCorrelations?.[0]; - const history = useHistory(); const [showStats, setShowStats] = useLocalStorage( 'apmFailedTransactionsShowAdvancedStats', false ); - const euiTheme = useTheme(); const toggleShowStats = useCallback(() => { setShowStats(!showStats); @@ -410,6 +410,30 @@ export function FailedTransactionsCorrelations({ [response.failedTransactionsCorrelations, sortField, sortDirection] ); + const [pinnedSignificantTerm, setPinnedSignificantTerm] = + useState(null); + const [selectedSignificantTerm, setSelectedSignificantTerm] = + useState(null); + + const selectedTerm = useMemo(() => { + if (!correlationTerms) { + return; + } else if (selectedSignificantTerm) { + return correlationTerms?.find( + (h) => + h.fieldName === selectedSignificantTerm.fieldName && + h.fieldValue === selectedSignificantTerm.fieldValue + ); + } else if (pinnedSignificantTerm) { + return correlationTerms.find( + (h) => + h.fieldName === pinnedSignificantTerm.fieldName && + h.fieldValue === pinnedSignificantTerm.fieldValue + ); + } + return correlationTerms[0]; + }, [correlationTerms, pinnedSignificantTerm, selectedSignificantTerm]); + const showCorrelationsTable = progress.isRunning || correlationTerms.length > 0; @@ -497,6 +521,41 @@ export function FailedTransactionsCorrelations({ + {selectedTerm && ( + + , + allTransactions: ( + + + + ), + allFailedTransactions: ( + + + + ), + focusTransaction: ( + + {selectedTerm?.fieldName}:{selectedTerm?.fieldValue} + + ), + }} + /> + + )} + @@ -581,6 +645,7 @@ export function FailedTransactionsCorrelations({ status={ progress.isRunning ? FETCH_STATUS.LOADING : FETCH_STATUS.SUCCESS } + setPinnedSignificantTerm={setPinnedSignificantTerm} setSelectedSignificantTerm={setSelectedSignificantTerm} selectedTerm={selectedTerm} onTableChange={onTableChange} diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index 75af7fae4ce12..db6f3ad63f00d 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -15,6 +15,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiText, EuiTitle, EuiToolTip, } from '@elastic/eui'; @@ -22,6 +23,7 @@ import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { enableInspectEsQueries, @@ -34,6 +36,7 @@ import { DEFAULT_PERCENTILE_THRESHOLD, } from '../../../../common/search_strategies/constants'; import { LatencyCorrelation } from '../../../../common/search_strategies/latency_correlations/types'; +import { FieldStats } from '../../../../common/search_strategies/field_stats_types'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; @@ -53,11 +56,13 @@ import { CorrelationsLog } from './correlations_log'; import { CorrelationsEmptyStatePrompt } from './empty_state_prompt'; import { CrossClusterSearchCompatibilityWarning } from './cross_cluster_search_warning'; import { CorrelationsProgressControls } from './progress_controls'; -import { FieldStats } from '../../../../common/search_strategies/field_stats_types'; +import { useTransactionColors } from './use_transaction_colors'; import { CorrelationsContextPopover } from './context_popover'; import { OnAddFilter } from './context_popover/top_values'; export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { + const transactionColors = useTransactionColors(); + const { core: { notifications, uiSettings }, } = useApmPluginContext(); @@ -98,19 +103,11 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { } }, [progress.error, notifications.toasts]); + const [pinnedSignificantTerm, setPinnedSignificantTerm] = + useState(null); const [selectedSignificantTerm, setSelectedSignificantTerm] = useState(null); - const selectedHistogram = useMemo( - () => - response.latencyCorrelations?.find( - (h) => - h.fieldName === selectedSignificantTerm?.fieldName && - h.fieldValue === selectedSignificantTerm?.fieldValue - ) ?? response.latencyCorrelations?.[0], - [response.latencyCorrelations, selectedSignificantTerm] - ); - const history = useHistory(); const trackApmEvent = useUiTracker({ app: 'apm' }); @@ -270,6 +267,25 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { [response.latencyCorrelations, sortField, sortDirection] ); + const selectedHistogram = useMemo(() => { + if (!histogramTerms) { + return; + } else if (selectedSignificantTerm) { + return histogramTerms?.find( + (h) => + h.fieldName === selectedSignificantTerm.fieldName && + h.fieldValue === selectedSignificantTerm.fieldValue + ); + } else if (pinnedSignificantTerm) { + return histogramTerms.find( + (h) => + h.fieldName === pinnedSignificantTerm.fieldName && + h.fieldValue === pinnedSignificantTerm.fieldValue + ); + } + return histogramTerms[0]; + }, [histogramTerms, pinnedSignificantTerm, selectedSignificantTerm]); + const showCorrelationsTable = progress.isRunning || histogramTerms.length > 0; const showCorrelationsEmptyStatePrompt = histogramTerms.length < 1 && @@ -315,6 +331,31 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { + {selectedHistogram && ( + + , + allTransactions: ( + + + + ), + focusTransaction: ( + + {selectedHistogram?.fieldName}:{selectedHistogram?.fieldValue} + + ), + }} + /> + + )} + void }) { status={ progress.isRunning ? FETCH_STATUS.LOADING : FETCH_STATUS.SUCCESS } + setPinnedSignificantTerm={setPinnedSignificantTerm} setSelectedSignificantTerm={setSelectedSignificantTerm} selectedTerm={selectedHistogram} onTableChange={onTableChange} diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_transaction_colors.ts b/x-pack/plugins/apm/public/components/app/correlations/use_transaction_colors.ts new file mode 100644 index 0000000000000..445640209cd29 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/use_transaction_colors.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useTheme } from '../../../hooks/use_theme'; + +export const useTransactionColors = () => { + const euiTheme = useTheme(); + return { + ALL_TRANSACTIONS: euiTheme.eui.euiColorVis1, + ALL_FAILED_TRANSACTIONS: euiTheme.eui.euiColorVis7, + FOCUS_TRANSACTION: euiTheme.eui.euiColorVis2, + }; +}; diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap index c8c7bf82dff04..ee68630daa469 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap @@ -151,7 +151,6 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` className="euiTableHeaderCell" data-test-subj="tableHeaderCell_handled_3" role="columnheader" - scope="col" style={ Object { "width": undefined, @@ -447,7 +446,6 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` className="euiTableHeaderCell" data-test-subj="tableHeaderCell_handled_3" role="columnheader" - scope="col" style={ Object { "width": undefined, @@ -1265,6 +1263,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` className="euiFlexItem euiFlexItem--flexGrowZero" >
- - } - onPageChange={[Function]} - onPageSizeChange={[Function]} - pagination={ + -
- -
- - - +
+
+ + + - -
- - -
- + + +
- - -
-
-
- - -
- + + +
+ +
+
+
+
+ +
`; diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 6c2989d54309d..77ad4fba1ab60 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -356,7 +356,7 @@ export class CsvGenerator { let table: Datatable | undefined; try { - table = tabifyDocs(results, index, { shallow: true }); + table = tabifyDocs(results, index, { shallow: true, includeIgnoredValues: true }); } catch (err) { this.logger.error(err); } diff --git a/x-pack/plugins/rollup/common/index.ts b/x-pack/plugins/rollup/common/index.ts index dffbfbd182092..c912a905d061d 100644 --- a/x-pack/plugins/rollup/common/index.ts +++ b/x-pack/plugins/rollup/common/index.ts @@ -14,6 +14,8 @@ export const PLUGIN = { minimumLicenseType: basicLicense, }; +export const MAJOR_VERSION = '8.0.0'; + export const CONFIG_ROLLUPS = 'rollups:enableIndexPatterns'; export const API_BASE_PATH = '/api/rollup'; diff --git a/x-pack/plugins/rollup/public/index.ts b/x-pack/plugins/rollup/public/index.ts index b70ce86493382..f740971b4bcb0 100644 --- a/x-pack/plugins/rollup/public/index.ts +++ b/x-pack/plugins/rollup/public/index.ts @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { PluginInitializerContext } from 'src/core/public'; import { RollupPlugin } from './plugin'; -export const plugin = () => new RollupPlugin(); +export const plugin = (ctx: PluginInitializerContext) => new RollupPlugin(ctx); diff --git a/x-pack/plugins/rollup/public/plugin.ts b/x-pack/plugins/rollup/public/plugin.ts index 0d345e326193c..e458a13ee0e0e 100644 --- a/x-pack/plugins/rollup/public/plugin.ts +++ b/x-pack/plugins/rollup/public/plugin.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'kibana/public'; import { rollupBadgeExtension, rollupToggleExtension } from './extend_index_management'; // @ts-ignore import { RollupIndexPatternCreationConfig } from './index_pattern_creation/rollup_index_pattern_creation_config'; @@ -23,6 +23,7 @@ import { IndexManagementPluginSetup } from '../../index_management/public'; import { setHttp, init as initDocumentation } from './crud_app/services/index'; import { setNotifications, setFatalErrors, setUiStatsReporter } from './kibana_services'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; +import { ClientConfigType } from './types'; export interface RollupPluginSetupDependencies { home?: HomePublicPluginSetup; @@ -32,10 +33,16 @@ export interface RollupPluginSetupDependencies { } export class RollupPlugin implements Plugin { + constructor(private ctx: PluginInitializerContext) {} + setup( core: CoreSetup, { home, management, indexManagement, usageCollection }: RollupPluginSetupDependencies ) { + const { + ui: { enabled: isRollupUiEnabled }, + } = this.ctx.config.get(); + setFatalErrors(core.fatalErrors); if (usageCollection) { setUiStatsReporter(usageCollection.reportUiCounter.bind(usageCollection, UIM_APP_NAME)); @@ -46,7 +53,7 @@ export class RollupPlugin implements Plugin { indexManagement.extensionsService.addToggle(rollupToggleExtension); } - if (home) { + if (home && isRollupUiEnabled) { home.featureCatalogue.register({ id: 'rollup_jobs', title: 'Rollups', @@ -61,33 +68,35 @@ export class RollupPlugin implements Plugin { }); } - const pluginName = i18n.translate('xpack.rollupJobs.appTitle', { - defaultMessage: 'Rollup Jobs', - }); + if (isRollupUiEnabled) { + const pluginName = i18n.translate('xpack.rollupJobs.appTitle', { + defaultMessage: 'Rollup Jobs', + }); - management.sections.section.data.registerApp({ - id: 'rollup_jobs', - title: pluginName, - order: 4, - async mount(params) { - const [coreStart] = await core.getStartServices(); + management.sections.section.data.registerApp({ + id: 'rollup_jobs', + title: pluginName, + order: 4, + async mount(params) { + const [coreStart] = await core.getStartServices(); - const { - chrome: { docTitle }, - } = coreStart; + const { + chrome: { docTitle }, + } = coreStart; - docTitle.change(pluginName); - params.setBreadcrumbs([{ text: pluginName }]); + docTitle.change(pluginName); + params.setBreadcrumbs([{ text: pluginName }]); - const { renderApp } = await import('./application'); - const unmountAppCallback = await renderApp(core, params); + const { renderApp } = await import('./application'); + const unmountAppCallback = await renderApp(core, params); - return () => { - docTitle.reset(); - unmountAppCallback(); - }; - }, - }); + return () => { + docTitle.reset(); + unmountAppCallback(); + }; + }, + }); + } } start(core: CoreStart) { diff --git a/x-pack/plugins/rollup/public/types.ts b/x-pack/plugins/rollup/public/types.ts new file mode 100644 index 0000000000000..dc5e55e9268f8 --- /dev/null +++ b/x-pack/plugins/rollup/public/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface ClientConfigType { + ui: { + enabled: boolean; + }; +} diff --git a/x-pack/plugins/rollup/server/config.ts b/x-pack/plugins/rollup/server/config.ts index d20b317422107..c0cca4bbb4d33 100644 --- a/x-pack/plugins/rollup/server/config.ts +++ b/x-pack/plugins/rollup/server/config.ts @@ -4,11 +4,90 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; + +export type RollupConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type RollupConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.rollup.enabled') === undefined) { + return completeConfig; + } -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), -}); + addDeprecation({ + configPath: 'xpack.rollup.enabled', + level: 'critical', + title: i18n.translate('xpack.rollupJobs.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.rollup.enabled" is deprecated', + }), + message: i18n.translate('xpack.rollupJobs.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Rollup Jobs UI, use the "xpack.rollup.ui.enabled" setting instead of "xpack.rollup.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.rollupJobs.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.rollupJobs.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.rollup.enabled" setting to "xpack.rollup.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; -export type RollupConfig = TypeOf; +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/rollup/server/index.ts b/x-pack/plugins/rollup/server/index.ts index e77e0e6f15d72..6ae1d9f24b8b9 100644 --- a/x-pack/plugins/rollup/server/index.ts +++ b/x-pack/plugins/rollup/server/index.ts @@ -5,14 +5,10 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { PluginInitializerContext } from 'src/core/server'; import { RollupPlugin } from './plugin'; -import { configSchema, RollupConfig } from './config'; + +export { config } from './config'; export const plugin = (pluginInitializerContext: PluginInitializerContext) => new RollupPlugin(pluginInitializerContext); - -export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], - schema: configSchema, -}; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts index d35d18d3b5958..16447e6b0f539 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts @@ -41,7 +41,7 @@ import { SPACE_IDS, } from '../../common/technical_rule_data_field_names'; import { ParsedTechnicalFields } from '../../common/parse_technical_fields'; -import { Dataset, RuleDataPluginService } from '../rule_data_plugin_service'; +import { Dataset, IRuleDataService } from '../rule_data_plugin_service'; const getEsQueryConfig: typeof getEsQueryConfigTyped = getEsQueryConfigNonTyped; const getSafeSortIds: typeof getSafeSortIdsTyped = getSafeSortIdsNonTyped; @@ -71,7 +71,7 @@ export interface ConstructorOptions { authorization: PublicMethodsOf; auditLogger?: AuditLogger; esClient: ElasticsearchClient; - ruleDataService: RuleDataPluginService; + ruleDataService: IRuleDataService; } export interface UpdateOptions { @@ -116,7 +116,7 @@ export class AlertsClient { private readonly authorization: PublicMethodsOf; private readonly esClient: ElasticsearchClient; private readonly spaceId: string | undefined; - private readonly ruleDataService: RuleDataPluginService; + private readonly ruleDataService: IRuleDataService; constructor(options: ConstructorOptions) { this.logger = options.logger; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts index 41ef5e4edb0d1..276ea070d6f87 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts @@ -13,8 +13,7 @@ import { loggingSystemMock } from 'src/core/server/mocks'; import { securityMock } from '../../../security/server/mocks'; import { AuditLogger } from '../../../security/server'; import { alertingAuthorizationMock } from '../../../alerting/server/authorization/alerting_authorization.mock'; -import { ruleDataPluginServiceMock } from '../rule_data_plugin_service/rule_data_plugin_service.mock'; -import { RuleDataPluginService } from '../rule_data_plugin_service'; +import { ruleDataServiceMock } from '../rule_data_plugin_service/rule_data_plugin_service.mock'; jest.mock('./alerts_client'); @@ -26,7 +25,7 @@ const alertsClientFactoryParams: AlertsClientFactoryProps = { getAlertingAuthorization: (_: KibanaRequest) => alertingAuthMock, securityPluginSetup, esClient: {} as ElasticsearchClient, - ruleDataService: ruleDataPluginServiceMock.create() as unknown as RuleDataPluginService, + ruleDataService: ruleDataServiceMock.create(), }; const fakeRequest = { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts index c1ff6d5d56ea9..8225394c2dba7 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts @@ -9,7 +9,7 @@ import { PublicMethodsOf } from '@kbn/utility-types'; import { ElasticsearchClient, KibanaRequest, Logger } from 'src/core/server'; import { AlertingAuthorization } from '../../../alerting/server'; import { SecurityPluginSetup } from '../../../security/server'; -import { RuleDataPluginService } from '../rule_data_plugin_service'; +import { IRuleDataService } from '../rule_data_plugin_service'; import { AlertsClient } from './alerts_client'; export interface AlertsClientFactoryProps { @@ -17,7 +17,7 @@ export interface AlertsClientFactoryProps { esClient: ElasticsearchClient; getAlertingAuthorization: (request: KibanaRequest) => PublicMethodsOf; securityPluginSetup: SecurityPluginSetup | undefined; - ruleDataService: RuleDataPluginService | null; + ruleDataService: IRuleDataService | null; } export class AlertsClientFactory { @@ -28,7 +28,7 @@ export class AlertsClientFactory { request: KibanaRequest ) => PublicMethodsOf; private securityPluginSetup!: SecurityPluginSetup | undefined; - private ruleDataService!: RuleDataPluginService | null; + private ruleDataService!: IRuleDataService | null; public initialize(options: AlertsClientFactoryProps) { /** diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts index 2be1f6875cd7e..8868d7959621d 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/bulk_update.test.ts @@ -19,8 +19,7 @@ import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mo import { alertingAuthorizationMock } from '../../../../alerting/server/authorization/alerting_authorization.mock'; import { AuditLogger } from '../../../../security/server'; import { AlertingAuthorizationEntity } from '../../../../alerting/server'; -import { ruleDataPluginServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock'; -import { RuleDataPluginService } from '../../rule_data_plugin_service'; +import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock'; const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); @@ -33,7 +32,7 @@ const alertsClientParams: jest.Mocked = { authorization: alertingAuthMock, esClient: esClientMock, auditLogger, - ruleDataService: ruleDataPluginServiceMock.create() as unknown as RuleDataPluginService, + ruleDataService: ruleDataServiceMock.create(), }; const DEFAULT_SPACE = 'test_default_space_id'; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts index b94a3b96312e4..5f9a20c14ea5b 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/find_alerts.test.ts @@ -18,8 +18,7 @@ import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mo import { alertingAuthorizationMock } from '../../../../alerting/server/authorization/alerting_authorization.mock'; import { AuditLogger } from '../../../../security/server'; import { AlertingAuthorizationEntity } from '../../../../alerting/server'; -import { ruleDataPluginServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock'; -import { RuleDataPluginService } from '../../rule_data_plugin_service'; +import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock'; const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); @@ -32,7 +31,7 @@ const alertsClientParams: jest.Mocked = { authorization: alertingAuthMock, esClient: esClientMock, auditLogger, - ruleDataService: ruleDataPluginServiceMock.create() as unknown as RuleDataPluginService, + ruleDataService: ruleDataServiceMock.create(), }; const DEFAULT_SPACE = 'test_default_space_id'; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts index 320e9f8a5fb1c..eaf6c0089ce12 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts @@ -19,8 +19,7 @@ import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mo import { alertingAuthorizationMock } from '../../../../alerting/server/authorization/alerting_authorization.mock'; import { AuditLogger } from '../../../../security/server'; import { AlertingAuthorizationEntity } from '../../../../alerting/server'; -import { ruleDataPluginServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock'; -import { RuleDataPluginService } from '../../rule_data_plugin_service'; +import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock'; const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); @@ -33,7 +32,7 @@ const alertsClientParams: jest.Mocked = { authorization: alertingAuthMock, esClient: esClientMock, auditLogger, - ruleDataService: ruleDataPluginServiceMock.create() as unknown as RuleDataPluginService, + ruleDataService: ruleDataServiceMock.create(), }; const DEFAULT_SPACE = 'test_default_space_id'; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts index 922011dcb5271..85527e26a9cd3 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts @@ -18,8 +18,7 @@ import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mo import { alertingAuthorizationMock } from '../../../../alerting/server/authorization/alerting_authorization.mock'; import { AuditLogger } from '../../../../security/server'; import { AlertingAuthorizationEntity } from '../../../../alerting/server'; -import { ruleDataPluginServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock'; -import { RuleDataPluginService } from '../../rule_data_plugin_service'; +import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock'; const alertingAuthMock = alertingAuthorizationMock.create(); const esClientMock = elasticsearchClientMock.createElasticsearchClient(); @@ -32,7 +31,7 @@ const alertsClientParams: jest.Mocked = { authorization: alertingAuthMock, esClient: esClientMock, auditLogger, - ruleDataService: ruleDataPluginServiceMock.create() as unknown as RuleDataPluginService, + ruleDataService: ruleDataServiceMock.create(), }; const DEFAULT_SPACE = 'test_default_space_id'; diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index 5331ab86be982..d6c5b61706415 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -12,7 +12,7 @@ import { PluginInitializerContext } from 'src/core/server'; import { RuleRegistryPlugin } from './plugin'; export type { RuleRegistryPluginSetupContract, RuleRegistryPluginStartContract } from './plugin'; -export { RuleDataPluginService } from './rule_data_plugin_service'; +export { IRuleDataService, RuleDataPluginService } from './rule_data_plugin_service'; export { RuleDataClient } from './rule_data_client'; export { IRuleDataClient } from './rule_data_client/types'; export type { diff --git a/x-pack/plugins/rule_registry/server/mocks.ts b/x-pack/plugins/rule_registry/server/mocks.ts index e9ec25ddcdaba..023de6aa6029c 100644 --- a/x-pack/plugins/rule_registry/server/mocks.ts +++ b/x-pack/plugins/rule_registry/server/mocks.ts @@ -7,12 +7,17 @@ import { alertsClientMock } from './alert_data_client/alerts_client.mock'; import { createRuleDataClientMock } from './rule_data_client/rule_data_client.mock'; -import { ruleDataPluginServiceMock } from './rule_data_plugin_service/rule_data_plugin_service.mock'; +import { + ruleDataServiceMock, + RuleDataServiceMock, +} from './rule_data_plugin_service/rule_data_plugin_service.mock'; import { createLifecycleAlertServicesMock } from './utils/lifecycle_alert_services_mock'; export const ruleRegistryMocks = { createLifecycleAlertServices: createLifecycleAlertServicesMock, - createRuleDataPluginService: ruleDataPluginServiceMock.create, + createRuleDataService: ruleDataServiceMock.create, createRuleDataClient: createRuleDataClientMock, createAlertsClientMock: alertsClientMock, }; + +export { RuleDataServiceMock }; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index b68f3eeb10669..334216ce41361 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -20,7 +20,7 @@ import { PluginStartContract as AlertingStart } from '../../alerting/server'; import { SecurityPluginSetup } from '../../security/server'; import { RuleRegistryPluginConfig } from './config'; -import { RuleDataPluginService } from './rule_data_plugin_service'; +import { IRuleDataService, RuleDataService } from './rule_data_plugin_service'; import { AlertsClientFactory } from './alert_data_client/alerts_client_factory'; import { AlertsClient } from './alert_data_client/alerts_client'; import { RacApiRequestHandlerContext, RacRequestHandlerContext } from './types'; @@ -35,7 +35,7 @@ export interface RuleRegistryPluginStartDependencies { } export interface RuleRegistryPluginSetupContract { - ruleDataService: RuleDataPluginService; + ruleDataService: IRuleDataService; } export interface RuleRegistryPluginStartContract { @@ -57,7 +57,7 @@ export class RuleRegistryPlugin private readonly logger: Logger; private readonly kibanaVersion: string; private readonly alertsClientFactory: AlertsClientFactory; - private ruleDataService: RuleDataPluginService | null; + private ruleDataService: IRuleDataService | null; private security: SecurityPluginSetup | undefined; constructor(initContext: PluginInitializerContext) { @@ -100,7 +100,7 @@ export class RuleRegistryPlugin } }; - this.ruleDataService = new RuleDataPluginService({ + this.ruleDataService = new RuleDataService({ logger, kibanaVersion, isWriteEnabled: isWriteEnabled(this.config, this.legacyConfig), diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts index c50a982741b0c..43e727e79b76b 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts @@ -5,13 +5,10 @@ * 2.0. */ -import type { PublicMethodsOf } from '@kbn/utility-types'; -import { RuleDataPluginService } from './rule_data_plugin_service'; +import { IRuleDataService } from './rule_data_plugin_service'; -type Schema = PublicMethodsOf; - -const createRuleDataPluginService = () => { - const mocked: jest.Mocked = { +export const ruleDataServiceMock = { + create: (): jest.Mocked => ({ getResourcePrefix: jest.fn(), getResourceName: jest.fn(), isWriteEnabled: jest.fn(), @@ -19,10 +16,9 @@ const createRuleDataPluginService = () => { initializeIndex: jest.fn(), findIndexByName: jest.fn(), findIndicesByFeature: jest.fn(), - }; - return mocked; + }), }; -export const ruleDataPluginServiceMock = { - create: createRuleDataPluginService, -}; +export const RuleDataServiceMock = jest + .fn, []>() + .mockImplementation(ruleDataServiceMock.create); diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts index 0617bc0a820ac..c5ec38ec8534e 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts @@ -17,6 +17,59 @@ import { Dataset, IndexOptions } from './index_options'; import { ResourceInstaller } from './resource_installer'; import { joinWithDash } from './utils'; +/** + * A service for creating and using Elasticsearch indices for alerts-as-data. + */ +export interface IRuleDataService { + /** + * Returns a prefix used in the naming scheme of index aliases, templates + * and other Elasticsearch resources that this service creates + * for alerts-as-data indices. + */ + getResourcePrefix(): string; + + /** + * Prepends a relative resource name with the resource prefix. + * @returns Full name of the resource. + * @example 'security.alerts' => '.alerts-security.alerts' + */ + getResourceName(relativeName: string): string; + + /** + * If write is enabled, everything works as usual. + * If it's disabled, writing to all alerts-as-data indices will be disabled, + * and also Elasticsearch resources associated with the indices will not be + * installed. + */ + isWriteEnabled(): boolean; + + /** + * Installs common Elasticsearch resources used by all alerts-as-data indices. + */ + initializeService(): void; + + /** + * Initializes alerts-as-data index and starts index bootstrapping right away. + * @param indexOptions Index parameters: names and resources. + * @returns Client for reading and writing data to this index. + */ + initializeIndex(indexOptions: IndexOptions): IRuleDataClient; + + /** + * Looks up the index information associated with the given registration context and dataset. + */ + findIndexByName(registrationContext: string, dataset: Dataset): IndexInfo | null; + + /** + * Looks up the index information associated with the given Kibana "feature". + * Note: features are used in RBAC. + */ + findIndicesByFeature(featureId: ValidFeatureId, dataset?: Dataset): IndexInfo[]; +} + +// TODO: This is a leftover. Remove its usage from the "observability" plugin and delete it. +export type RuleDataPluginService = IRuleDataService; + interface ConstructorOptions { getClusterClient: () => Promise; logger: Logger; @@ -24,10 +77,7 @@ interface ConstructorOptions { isWriteEnabled: boolean; } -/** - * A service for creating and using Elasticsearch indices for alerts-as-data. - */ -export class RuleDataPluginService { +export class RuleDataService implements IRuleDataService { private readonly indicesByBaseName: Map; private readonly indicesByFeatureId: Map; private readonly resourceInstaller: ResourceInstaller; @@ -49,37 +99,18 @@ export class RuleDataPluginService { this.isInitialized = false; } - /** - * Returns a prefix used in the naming scheme of index aliases, templates - * and other Elasticsearch resources that this service creates - * for alerts-as-data indices. - */ public getResourcePrefix(): string { return INDEX_PREFIX; } - /** - * Prepends a relative resource name with the resource prefix. - * @returns Full name of the resource. - * @example 'security.alerts' => '.alerts-security.alerts' - */ public getResourceName(relativeName: string): string { return joinWithDash(this.getResourcePrefix(), relativeName); } - /** - * If write is enabled, everything works as usual. - * If it's disabled, writing to all alerts-as-data indices will be disabled, - * and also Elasticsearch resources associated with the indices will not be - * installed. - */ public isWriteEnabled(): boolean { return this.options.isWriteEnabled; } - /** - * Installs common Elasticsearch resources used by all alerts-as-data indices. - */ public initializeService(): void { // Run the installation of common resources and handle exceptions. this.installCommonResources = this.resourceInstaller @@ -93,11 +124,6 @@ export class RuleDataPluginService { this.isInitialized = true; } - /** - * Initializes alerts-as-data index and starts index bootstrapping right away. - * @param indexOptions Index parameters: names and resources. - * @returns Client for reading and writing data to this index. - */ public initializeIndex(indexOptions: IndexOptions): IRuleDataClient { if (!this.isInitialized) { throw new Error( @@ -156,18 +182,11 @@ export class RuleDataPluginService { }); } - /** - * Looks up the index information associated with the given registration context and dataset. - */ public findIndexByName(registrationContext: string, dataset: Dataset): IndexInfo | null { const baseName = this.getResourceName(`${registrationContext}.${dataset}`); return this.indicesByBaseName.get(baseName) ?? null; } - /** - * Looks up the index information associated with the given Kibana "feature". - * Note: features are used in RBAC. - */ public findIndicesByFeature(featureId: ValidFeatureId, dataset?: Dataset): IndexInfo[] { const foundIndices = this.indicesByFeatureId.get(featureId) ?? []; return dataset ? foundIndices.filter((i) => i.indexOptions.dataset === dataset) : foundIndices; diff --git a/x-pack/plugins/security/server/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts index 75c8229bb37d6..d8906d91f152b 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -878,6 +878,42 @@ describe('#atSpace', () => { `); }); }); + + test('omits login privilege when requireLoginAction: false', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: true, + username: 'foo-username', + index: {}, + application: { + [application]: { + 'space:space_1': { + [mockActions.version]: true, + }, + }, + }, + }); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.atSpace('space_1', {}, { requireLoginAction: false }); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({ + body: { + index: [], + application: [ + { + application, + resources: [`space:space_1`], + privileges: [mockActions.version], + }, + ], + }, + }); + }); }); describe('#atSpaces', () => { @@ -2083,6 +2119,42 @@ describe('#atSpaces', () => { `); }); }); + + test('omits login privilege when requireLoginAction: false', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: true, + username: 'foo-username', + index: {}, + application: { + [application]: { + 'space:space_1': { + [mockActions.version]: true, + }, + }, + }, + }); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.atSpaces(['space_1'], {}, { requireLoginAction: false }); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({ + body: { + index: [], + application: [ + { + application, + resources: [`space:space_1`], + privileges: [mockActions.version], + }, + ], + }, + }); + }); }); describe('#globally', () => { @@ -2937,4 +3009,40 @@ describe('#globally', () => { `); }); }); + + test('omits login privilege when requireLoginAction: false', async () => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient({ + has_all_requested: true, + username: 'foo-username', + index: {}, + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.version]: true, + }, + }, + }, + }); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + () => Promise.resolve(mockClusterClient), + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + await checkPrivileges.globally({}, { requireLoginAction: false }); + + expect(mockScopedClusterClient.asCurrentUser.security.hasPrivileges).toHaveBeenCalledWith({ + body: { + index: [], + application: [ + { + application, + resources: [GLOBAL_RESOURCE], + privileges: [mockActions.version], + }, + ], + }, + }); + }); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts index 3a35cf164ad85..36c364f1ff7da 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -13,6 +13,7 @@ import { GLOBAL_RESOURCE } from '../../common/constants'; import { ResourceSerializer } from './resource_serializer'; import type { CheckPrivileges, + CheckPrivilegesOptions, CheckPrivilegesPayload, CheckPrivilegesResponse, HasPrivilegesResponse, @@ -41,14 +42,20 @@ export function checkPrivilegesWithRequestFactory( return function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges { const checkPrivilegesAtResources = async ( resources: string[], - privileges: CheckPrivilegesPayload + privileges: CheckPrivilegesPayload, + { requireLoginAction = true }: CheckPrivilegesOptions = {} ): Promise => { const kibanaPrivileges = Array.isArray(privileges.kibana) ? privileges.kibana : privileges.kibana ? [privileges.kibana] : []; - const allApplicationPrivileges = uniq([actions.version, actions.login, ...kibanaPrivileges]); + + const allApplicationPrivileges = uniq([ + actions.version, + ...(requireLoginAction ? [actions.login] : []), + ...kibanaPrivileges, + ]); const clusterClient = await getClusterClient(); const { body } = await clusterClient.asScoped(request).asCurrentUser.security.hasPrivileges({ @@ -135,18 +142,26 @@ export function checkPrivilegesWithRequestFactory( }; return { - async atSpace(spaceId: string, privileges: CheckPrivilegesPayload) { + async atSpace( + spaceId: string, + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ) { const spaceResource = ResourceSerializer.serializeSpaceResource(spaceId); - return await checkPrivilegesAtResources([spaceResource], privileges); + return await checkPrivilegesAtResources([spaceResource], privileges, options); }, - async atSpaces(spaceIds: string[], privileges: CheckPrivilegesPayload) { + async atSpaces( + spaceIds: string[], + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ) { const spaceResources = spaceIds.map((spaceId) => ResourceSerializer.serializeSpaceResource(spaceId) ); - return await checkPrivilegesAtResources(spaceResources, privileges); + return await checkPrivilegesAtResources(spaceResources, privileges, options); }, - async globally(privileges: CheckPrivilegesPayload) { - return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges); + async globally(privileges: CheckPrivilegesPayload, options?: CheckPrivilegesOptions) { + return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges, options); }, }; }; diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts index 547782bbd1ba1..9fd14c6d29806 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts @@ -8,6 +8,7 @@ import { httpServerMock } from 'src/core/server/mocks'; import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; +import type { CheckPrivilegesOptions } from './types'; test(`checkPrivileges.atSpace when spaces is enabled`, async () => { const expectedResult = Symbol(); @@ -25,13 +26,18 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { namespaceToSpaceId: jest.fn(), }) )(request); - const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }); + const options: CheckPrivilegesOptions = { requireLoginAction: true }; + const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }, options); expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, { - kibana: privilegeOrPrivileges, - }); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith( + spaceId, + { + kibana: privilegeOrPrivileges, + }, + options + ); }); test(`checkPrivileges.globally when spaces is disabled`, async () => { @@ -46,9 +52,13 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { mockCheckPrivilegesWithRequest, () => undefined )(request); - const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }); + const options: CheckPrivilegesOptions = { requireLoginAction: true }; + const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }, options); expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: privilegeOrPrivileges }); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith( + { kibana: privilegeOrPrivileges }, + options + ); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts index 4ce59c8706270..d4e335ba04058 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts @@ -9,13 +9,15 @@ import type { KibanaRequest } from 'src/core/server'; import type { SpacesService } from '../plugin'; import type { + CheckPrivilegesOptions, CheckPrivilegesPayload, CheckPrivilegesResponse, CheckPrivilegesWithRequest, } from './types'; export type CheckPrivilegesDynamically = ( - privileges: CheckPrivilegesPayload + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions ) => Promise; export type CheckPrivilegesDynamicallyWithRequest = ( @@ -28,11 +30,15 @@ export function checkPrivilegesDynamicallyWithRequestFactory( ): CheckPrivilegesDynamicallyWithRequest { return function checkPrivilegesDynamicallyWithRequest(request: KibanaRequest) { const checkPrivileges = checkPrivilegesWithRequest(request); - return async function checkPrivilegesDynamically(privileges: CheckPrivilegesPayload) { + + return async function checkPrivilegesDynamically( + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ) { const spacesService = getSpacesService(); return spacesService - ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privileges) - : await checkPrivileges.globally(privileges); + ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privileges, options) + : await checkPrivileges.globally(privileges, options); }; }; } diff --git a/x-pack/plugins/security/server/authorization/types.ts b/x-pack/plugins/security/server/authorization/types.ts index 8bfe892840637..aee059fb8becb 100644 --- a/x-pack/plugins/security/server/authorization/types.ts +++ b/x-pack/plugins/security/server/authorization/types.ts @@ -29,6 +29,18 @@ export interface HasPrivilegesResponse { }; } +/** + * Options to influce the privilege checks. + */ +export interface CheckPrivilegesOptions { + /** + * Whether or not the `login` action should be required (default: true). + * Setting this to false is not advised except for special circumstances, when you do not require + * the request to belong to a user capable of logging into Kibana. + */ + requireLoginAction?: boolean; +} + export interface CheckPrivilegesResponse { hasAllRequested: boolean; username: string; @@ -59,12 +71,20 @@ export interface CheckPrivilegesResponse { export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges; export interface CheckPrivileges { - atSpace(spaceId: string, privileges: CheckPrivilegesPayload): Promise; + atSpace( + spaceId: string, + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ): Promise; atSpaces( spaceIds: string[], - privileges: CheckPrivilegesPayload + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions + ): Promise; + globally( + privileges: CheckPrivilegesPayload, + options?: CheckPrivilegesOptions ): Promise; - globally(privileges: CheckPrivilegesPayload): Promise; } export interface CheckPrivilegesPayload { diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 1baf3fd4aac50..4034a7a79e6dd 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -63,7 +63,7 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "P30D", }, "showInsecureClusterWarning": true, @@ -117,7 +117,7 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "P30D", }, "showInsecureClusterWarning": true, @@ -170,7 +170,7 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "P30D", }, "showInsecureClusterWarning": true, @@ -1768,7 +1768,7 @@ describe('createConfig()', () => { expect(createMockConfig().session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "P30D", } `); @@ -1818,7 +1818,7 @@ describe('createConfig()', () => { }) ).toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT0.456S", } `); @@ -1852,7 +1852,7 @@ describe('createConfig()', () => { createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts(provider) ).toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT0.456S", } `); @@ -1933,14 +1933,14 @@ describe('createConfig()', () => { expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT0.654S", } `); expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT11M5.544S", } `); @@ -1957,7 +1957,7 @@ describe('createConfig()', () => { expect(configWithGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": "PT1H", + "idleTimeout": "PT8H", "lifespan": "PT0.654S", } `); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 89918e73369d3..23a1fd2efa382 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -211,7 +211,7 @@ export const ConfigSchema = schema.object({ ), session: schema.object({ idleTimeout: schema.oneOf([schema.duration(), schema.literal(null)], { - defaultValue: schema.duration().validate('1h'), + defaultValue: schema.duration().validate('8h'), }), lifespan: schema.oneOf([schema.duration(), schema.literal(null)], { defaultValue: schema.duration().validate('30d'), diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts index 83f09ef017b01..3a53a2422770c 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts @@ -47,7 +47,7 @@ describe('Security UsageCollector', () => { enabledAuthProviders: ['basic'], loginSelectorEnabled: false, httpAuthSchemes: ['apikey', 'bearer'], - sessionIdleTimeoutInMinutes: 60, + sessionIdleTimeoutInMinutes: 480, sessionLifespanInMinutes: 43200, sessionCleanupInMinutes: 60, }; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 5a7e19e2cdd05..442718e0975ee 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ENABLE_ITOM } from '../../actions/server/constants/connectors'; import type { TransformConfigSchema } from './transforms/types'; import { ENABLE_CASE_CONNECTOR } from '../../cases/common'; import { METADATA_TRANSFORMS_PATTERN } from './endpoint/constants'; @@ -296,21 +298,27 @@ export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID]; */ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.email', - '.slack', + '.index', + '.jira', '.pagerduty', - '.swimlane', - '.webhook', + '.resilient', '.servicenow', '.servicenow-sir', - '.jira', - '.resilient', + '.slack', + '.swimlane', '.teams', + '.webhook', ]; if (ENABLE_CASE_CONNECTOR) { NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.case'); } +// TODO: Remove when ITOM is ready +if (ENABLE_ITOM) { + NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.servicenow-itom'); +} + export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions'; export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_actions.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_actions.ts index 246cf059f4cbd..5cc564ee3d41d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_actions.ts @@ -66,7 +66,11 @@ export const indexFleetActionsForHost = async ( const actionResponse = fleetActionGenerator.generateResponse({ action_id: action.action_id, agent_id: agentId, - action_data: action.data, + action_data: { + ...action.data, + // add ack to 4/5th of fleet response + ack: fleetActionGenerator.randomFloat() < 0.8 ? true : undefined, + }, }); esClient diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index fb29297eb5929..d7ad417fc7d3f 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -64,6 +64,7 @@ export interface LogsEndpointActionResponse { export interface EndpointActionData { command: ISOLATION_ACTIONS; comment?: string; + ack?: boolean; } export interface EndpointAction { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts index 674114188632b..7b792f8d560f1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { ALERT_FLYOUT, CELL_TEXT, JSON_LINES, TABLE_ROWS } from '../../screens/alerts_details'; +import { ALERT_FLYOUT, CELL_TEXT, JSON_TEXT, TABLE_ROWS } from '../../screens/alerts_details'; import { expandFirstAlert, waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; -import { openJsonView, openTable, scrollJsonViewToBottom } from '../../tasks/alerts_details'; +import { openJsonView, openTable } from '../../tasks/alerts_details'; import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; import { cleanKibana } from '../../tasks/common'; import { esArchiverLoad } from '../../tasks/es_archiver'; @@ -36,20 +36,14 @@ describe('Alert details with unmapped fields', () => { }); it('Displays the unmapped field on the JSON view', () => { - const expectedUnmappedField = { line: 2, text: ' "unmapped": "This is the unmapped field"' }; + const expectedUnmappedValue = 'This is the unmapped field'; openJsonView(); - scrollJsonViewToBottom(); - cy.get(ALERT_FLYOUT) - .find(JSON_LINES) - .then((elements) => { - const length = elements.length; - cy.wrap(elements) - .eq(length - expectedUnmappedField.line) - .invoke('text') - .should('include', expectedUnmappedField.text); - }); + cy.get(JSON_TEXT).then((x) => { + const parsed = JSON.parse(x.text()); + expect(parsed._source.unmapped).to.equal(expectedUnmappedValue); + }); }); it('Displays the unmapped field on the table', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts new file mode 100644 index 0000000000000..94418e61b4053 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts @@ -0,0 +1,44 @@ +/* + * 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 { getBuildingBlockRule } from '../../objects/rule'; +import { OVERVIEW_ALERTS_HISTOGRAM } from '../../screens/overview'; +import { OVERVIEW } from '../../screens/security_header'; +import { waitForAlertsIndexToBeCreated, waitForAlertsPanelToBeLoaded } from '../../tasks/alerts'; +import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { waitForAlertsToPopulate, waitForTheRuleToBeExecuted } from '../../tasks/create_new_rule'; +import { loginAndWaitForPage, loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { navigateFromHeaderTo } from '../../tasks/security_header'; +import { ALERTS_URL, DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; + +const EXPECTED_NUMBER_OF_ALERTS = 16; + +describe('Alerts generated by building block rules', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + }); + + it('Alerts should be visible on the Rule Detail page and not visible on the Overview page', () => { + createCustomRuleActivated(getBuildingBlockRule()); + loginAndWaitForPage(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + waitForTheRuleToBeExecuted(); + + // Check that generated events are visible on the Details page + waitForAlertsToPopulate(EXPECTED_NUMBER_OF_ALERTS); + + navigateFromHeaderTo(OVERVIEW); + + // Check that generated events are hidden on the Overview page + cy.get(OVERVIEW_ALERTS_HISTOGRAM).should('contain.text', 'No data to display'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts index b3c6abcd8e426..f15e7adbbca44 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts @@ -10,7 +10,7 @@ import { cleanKibana, reload } from '../../tasks/common'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { - JSON_LINES, + JSON_TEXT, TABLE_CELL, TABLE_ROWS, THREAT_DETAILS_VIEW, @@ -28,11 +28,7 @@ import { viewThreatIntelTab, } from '../../tasks/alerts'; import { createCustomIndicatorRule } from '../../tasks/api_calls/rules'; -import { - openJsonView, - openThreatIndicatorDetails, - scrollJsonViewToBottom, -} from '../../tasks/alerts_details'; +import { openJsonView, openThreatIndicatorDetails } from '../../tasks/alerts_details'; import { ALERTS_URL } from '../../urls/navigation'; import { addsFieldsToTimeline } from '../../tasks/rule_details'; @@ -76,26 +72,39 @@ describe('CTI Enrichment', () => { it('Displays persisted enrichments on the JSON view', () => { const expectedEnrichment = [ - { line: 4, text: ' "threat": {' }, { - line: 3, - text: ' "enrichments": "{\\"indicator\\":{\\"first_seen\\":\\"2021-03-10T08:02:14.000Z\\",\\"file\\":{\\"size\\":80280,\\"pe\\":{},\\"type\\":\\"elf\\",\\"hash\\":{\\"sha256\\":\\"a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3\\",\\"tlsh\\":\\"6D7312E017B517CC1371A8353BED205E9128223972AE35302E97528DF957703BAB2DBE\\",\\"ssdeep\\":\\"1536:87vbq1lGAXSEYQjbChaAU2yU23M51DjZgSQAvcYkFtZTjzBht5:8D+CAXFYQChaAUk5ljnQssL\\",\\"md5\\":\\"9b6c3518a91d23ed77504b5416bfb5b3\\"}},\\"type\\":\\"file\\"},\\"matched\\":{\\"atomic\\":\\"a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3\\",\\"field\\":\\"myhash.mysha256\\",\\"id\\":\\"84cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f\\",\\"index\\":\\"logs-ti_abusech.malware\\",\\"type\\":\\"indicator_match_rule\\"}}"', + indicator: { + first_seen: '2021-03-10T08:02:14.000Z', + file: { + size: 80280, + pe: {}, + type: 'elf', + hash: { + sha256: 'a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3', + tlsh: '6D7312E017B517CC1371A8353BED205E9128223972AE35302E97528DF957703BAB2DBE', + ssdeep: + '1536:87vbq1lGAXSEYQjbChaAU2yU23M51DjZgSQAvcYkFtZTjzBht5:8D+CAXFYQChaAUk5ljnQssL', + md5: '9b6c3518a91d23ed77504b5416bfb5b3', + }, + }, + type: 'file', + }, + matched: { + atomic: 'a04ac6d98ad989312783d4fe3456c53730b212c79a426fb215708b6c6daa3de3', + field: 'myhash.mysha256', + id: '84cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f', + index: 'logs-ti_abusech.malware', + type: 'indicator_match_rule', + }, }, - { line: 2, text: ' }' }, ]; expandFirstAlert(); openJsonView(); - scrollJsonViewToBottom(); - - cy.get(JSON_LINES).then((elements) => { - const length = elements.length; - expectedEnrichment.forEach((enrichment) => { - cy.wrap(elements) - .eq(length - enrichment.line) - .invoke('text') - .should('include', enrichment.text); - }); + + cy.get(JSON_TEXT).then((x) => { + const parsed = JSON.parse(x.text()); + expect(parsed._source.threat.enrichments).to.deep.equal(expectedEnrichment); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index 871e50821b58c..8735b8d49974c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -102,6 +102,12 @@ import { waitForAlertsToPopulate, waitForTheRuleToBeExecuted, } from '../../tasks/create_new_rule'; +import { + SCHEDULE_INTERVAL_AMOUNT_INPUT, + SCHEDULE_INTERVAL_UNITS_INPUT, + SCHEDULE_LOOKBACK_AMOUNT_INPUT, + SCHEDULE_LOOKBACK_UNITS_INPUT, +} from '../../screens/create_new_rule'; import { goBackToRuleDetails, waitForKibana } from '../../tasks/edit_rule'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; @@ -383,6 +389,19 @@ describe('indicator match', () => { getIndicatorMappingComboField(2).should('not.exist'); }); }); + + describe('Schedule', () => { + it('IM rule has 1h time interval and lookback by default', () => { + selectIndicatorMatchType(); + fillDefineIndicatorMatchRuleAndContinue(getNewThreatIndicatorRule()); + fillAboutRuleAndContinue(getNewThreatIndicatorRule()); + + cy.get(SCHEDULE_INTERVAL_AMOUNT_INPUT).invoke('val').should('eql', '1'); + cy.get(SCHEDULE_INTERVAL_UNITS_INPUT).invoke('val').should('eql', 'h'); + cy.get(SCHEDULE_LOOKBACK_AMOUNT_INPUT).invoke('val').should('eql', '5'); + cy.get(SCHEDULE_LOOKBACK_UNITS_INPUT).invoke('val').should('eql', 'm'); + }); + }); }); describe('Generating signals', () => { diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 4b061865d632b..27973854097db 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -58,6 +58,7 @@ export interface CustomRule { lookBack: Interval; timeline: CompleteTimeline; maxSignals: number; + buildingBlockType?: string; } export interface ThresholdRule extends CustomRule { @@ -188,6 +189,25 @@ export const getNewRule = (): CustomRule => ({ maxSignals: 100, }); +export const getBuildingBlockRule = (): CustomRule => ({ + customQuery: 'host.name: *', + index: getIndexPatterns(), + name: 'Building Block Rule Test', + description: 'The new rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['http://example.com/', 'https://example.com/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [getMitre1(), getMitre2()], + note: '# test markdown', + runsEvery: getRunsEvery(), + lookBack: getLookBack(), + timeline: getTimeline(), + maxSignals: 100, + buildingBlockType: 'default', +}); + export const getUnmappedRule = (): CustomRule => ({ customQuery: '*:*', index: ['unmapped*'], diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts index c740a669d059a..584fba05452f0 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -28,6 +28,8 @@ export const JSON_LINES = '.euiCodeBlock__line'; export const JSON_VIEW_TAB = '[data-test-subj="jsonViewTab"]'; +export const JSON_TEXT = '[data-test-subj="jsonView"]'; + export const TABLE_CELL = '.euiTableRowCell'; export const TABLE_TAB = '[data-test-subj="tableTab"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 3510df6186870..aadaa5dfa0d88 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -201,6 +201,12 @@ export const SCHEDULE_INTERVAL_AMOUNT_INPUT = export const SCHEDULE_INTERVAL_UNITS_INPUT = '[data-test-subj="detectionEngineStepScheduleRuleInterval"] [data-test-subj="timeType"]'; +export const SCHEDULE_LOOKBACK_AMOUNT_INPUT = + '[data-test-subj="detectionEngineStepScheduleRuleFrom"] [data-test-subj="interval"]'; + +export const SCHEDULE_LOOKBACK_UNITS_INPUT = + '[data-test-subj="detectionEngineStepScheduleRuleFrom"] [data-test-subj="timeType"]'; + export const SEVERITY_DROPDOWN = '[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index 1376a39e5ee79..1945b7e3ce3e7 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -166,3 +166,5 @@ export const OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON = export const OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT = `${OVERVIEW_RISKY_HOSTS_LINKS} [data-test-subj="header-panel-subtitle"]`; export const OVERVIEW_RISKY_HOSTS_ENABLE_MODULE_BUTTON = '[data-test-subj="risky-hosts-enable-module-button"]'; + +export const OVERVIEW_ALERTS_HISTOGRAM = '[data-test-subj="alerts-histogram-panel"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 2e412bbed6fdc..bc3a4282df1c9 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -28,6 +28,8 @@ export const CLOSE_TIMELINE_BTN = '[data-test-subj="close-timeline"]'; export const COMBO_BOX = '.euiComboBoxOption__content'; +export const COMBO_BOX_INPUT = '[data-test-subj="comboBoxInput"]'; + export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]'; export const CREATE_NEW_TIMELINE_TEMPLATE = '[data-test-subj="template-timeline-new"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index 04ff0fcabc081..fd2838e5b3caa 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -114,6 +114,7 @@ export const createCustomRuleActivated = ( enabled: true, tags: ['rule1'], max_signals: maxSignals, + building_block_type: rule.buildingBlockType, }, headers: { 'kbn-xsrf': 'cypress-creds' }, failOnStatusCode: false, diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 4c6b73de80940..c7cb56c89e9df 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -20,6 +20,7 @@ import { CASE, CLOSE_TIMELINE_BTN, COMBO_BOX, + COMBO_BOX_INPUT, CREATE_NEW_TIMELINE, FIELD_BROWSER, ID_HEADER_FIELD, @@ -164,9 +165,12 @@ export const addDataProvider = (filter: TimelineFilter): Cypress.Chainable diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index b152ccd546170..1803ab2b67455 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -10,7 +10,10 @@ import styled from 'styled-components'; import { EuiPanel } from '@elastic/eui'; import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import { AppLeaveHandler } from '../../../../../../../src/core/public'; -import { KibanaPageTemplate } from '../../../../../../../src/plugins/kibana_react/public'; +import { + KibanaPageTemplate, + NO_DATA_PAGE_TEMPLATE_PROPS, +} from '../../../../../../../src/plugins/kibana_react/public'; import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation'; import { TimelineId } from '../../../../common/types/timeline'; import { getTimelineShowStatusByIdSelector } from '../../../timelines/components/flyout/selectors'; @@ -23,12 +26,7 @@ import { } from './bottom_bar'; import { useShowTimeline } from '../../../common/utils/timeline/use_show_timeline'; import { gutterTimeline } from '../../../common/lib/helpers'; -import { useSourcererScope } from '../../../common/containers/sourcerer'; -import { OverviewEmpty } from '../../../overview/components/overview_empty'; -import { ENDPOINT_METADATA_INDEX } from '../../../../common/constants'; -import { useFetchIndex } from '../../../common/containers/source'; - -/* eslint-disable react/display-name */ +import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view'; /** * Need to apply the styles via a className to effect the containing bottom bar @@ -77,16 +75,12 @@ export const SecuritySolutionTemplateWrapper: React.FC getTimelineShowStatus(state, TimelineId.active) ); - const endpointMetadataIndex = useMemo(() => { - return [ENDPOINT_METADATA_INDEX]; - }, []); - const [, { indexExists: metadataIndexExists }] = useFetchIndex(endpointMetadataIndex, true); - const { indicesExist } = useSourcererScope(); - const securityIndicesExist = indicesExist || metadataIndexExists; + const showEmptyState = useShowPagesWithEmptyView(); + const emptyStateProps = showEmptyState ? NO_DATA_PAGE_TEMPLATE_PROPS : {}; // StyledKibanaPageTemplate is a styled EuiPageTemplate. Security solution currently passes the header and page content as the children of StyledKibanaPageTemplate, as opposed to using the pageHeader prop, which may account for any style discrepancies, such as the bottom border not extending the full width of the page, between EuiPageTemplate and the security solution pages. - return securityIndicesExist ? ( + return ( - - - {children} - + {showEmptyState ? ( + children + ) : ( + <> + + + {children} + + + )} - ) : ( - ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap index d367c68586be1..772badd80ce53 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/alert_summary_view.test.tsx.snap @@ -86,7 +86,6 @@ exports[`AlertSummaryView Behavior event code renders additional summary rows 1` class="euiTableHeaderCell" data-test-subj="tableHeaderCell_title_0" role="columnheader" - scope="col" style="width: 220px;" >
- open +
+ open +
- xxx +
+ xxx +
- low +
+ low +
- 21 +
+ 21 +
- windows-native +
+ windows-native +
- administrator +
+ administrator +
- open +
+ open +
- xxx +
+ xxx +
- low +
+ low +
- 21 +
+ 21 +
- windows-native +
+ windows-native +
- administrator +
+ administrator +
{ - "_id": "pEMaMmkBUV60JmNWmWVi", - "_index": "filebeat-8.0.0-2019.02.19-000001", + "_index": ".ds-logs-endpoint.events.network-default-2021.09.28-000001", + "_id": "TUWyf3wBFCFU0qRJTauW", "_score": 1, - "_type": "_doc", - "@timestamp": "2019-02-28T16:50:54.621Z", - "agent": { - "ephemeral_id": "9d391ef2-a734-4787-8891-67031178c641", - "hostname": "siem-kibana", - "id": "5de03d5f-52f3-482e-91d4-853c7de073c3", - "type": "filebeat", - "version": "8.0.0" - }, - "cloud": { - "availability_zone": "projects/189716325846/zones/us-east1-b", - "instance": { - "id": "5412578377715150143", - "name": "siem-kibana" + "_source": { + "agent": { + "id": "2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624", + "type": "endpoint", + "version": "8.0.0-SNAPSHOT" }, - "machine": { - "type": "projects/189716325846/machineTypes/n1-standard-1" + "process": { + "Ext": { + "ancestry": [ + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=", + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA==" + ] + }, + "name": "filebeat", + "pid": 22535, + "entity_id": "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA=", + "executable": "/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat" }, - "project": { - "id": "elastic-beats" + "destination": { + "address": "127.0.0.1", + "port": 9200, + "ip": "127.0.0.1" }, - "provider": "gce" - }, - "destination": { - "bytes": 584, - "ip": "10.47.8.200", - "packets": 4, - "port": 902 + "source": { + "address": "127.0.0.1", + "port": 54146, + "ip": "127.0.0.1" + }, + "message": "Endpoint network event", + "network": { + "transport": "tcp", + "type": "ipv4" + }, + "@timestamp": "2021-10-14T16:45:58.0310772Z", + "ecs": { + "version": "1.11.0" + }, + "data_stream": { + "namespace": "default", + "type": "logs", + "dataset": "endpoint.events.network" + }, + "elastic": { + "agent": { + "id": "12345" + } + }, + "host": { + "hostname": "test-linux-1", + "os": { + "Ext": { + "variant": "Debian" + }, + "kernel": "4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)", + "name": "Linux", + "family": "debian", + "type": "linux", + "version": "10", + "platform": "debian", + "full": "Debian 10" + }, + "ip": [ + "127.0.0.1", + "::1", + "10.1.2.3", + "2001:0DB8:AC10:FE01::" + ], + "name": "test-linux-1", + "id": "76ea303129f249aa7382338e4263eac1", + "mac": [ + "aa:bb:cc:dd:ee:ff" + ], + "architecture": "x86_64" + }, + "event": { + "agent_id_status": "verified", + "sequence": 44872, + "ingested": "2021-10-14T16:46:04Z", + "created": "2021-10-14T16:45:58.0310772Z", + "kind": "event", + "module": "endpoint", + "action": "connection_attempted", + "id": "MKPXftjGeHiQzUNj++++nn6R", + "category": [ + "network" + ], + "type": [ + "start" + ], + "dataset": "endpoint.events.network", + "outcome": "unknown" + }, + "user": { + "Ext": { + "real": { + "name": "root", + "id": 0 + } + }, + "name": "root", + "id": 0 + }, + "group": { + "Ext": { + "real": { + "name": "root", + "id": 0 + } + }, + "name": "root", + "id": 0 + } }, - "event": { - "kind": "event" + "fields": { + "host.os.full.text": [ + "Debian 10" + ], + "event.category": [ + "network" + ], + "process.name.text": [ + "filebeat" + ], + "host.os.name.text": [ + "Linux" + ], + "host.os.full": [ + "Debian 10" + ], + "host.hostname": [ + "test-linux-1" + ], + "process.pid": [ + 22535 + ], + "host.mac": [ + "42:01:0a:c8:00:32" + ], + "elastic.agent.id": [ + "abcdefg-f6d5-4ce6-915d-8f1f8f413624" + ], + "host.os.version": [ + "10" + ], + "host.os.name": [ + "Linux" + ], + "source.ip": [ + "127.0.0.1" + ], + "destination.address": [ + "127.0.0.1" + ], + "host.name": [ + "test-linux-1" + ], + "event.agent_id_status": [ + "verified" + ], + "event.kind": [ + "event" + ], + "event.outcome": [ + "unknown" + ], + "group.name": [ + "root" + ], + "user.id": [ + "0" + ], + "host.os.type": [ + "linux" + ], + "process.Ext.ancestry": [ + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=", + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA==" + ], + "user.Ext.real.id": [ + "0" + ], + "data_stream.type": [ + "logs" + ], + "host.architecture": [ + "x86_64" + ], + "process.name": [ + "filebeat" + ], + "agent.id": [ + "2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624" + ], + "source.port": [ + 54146 + ], + "ecs.version": [ + "1.11.0" + ], + "event.created": [ + "2021-10-14T16:45:58.031Z" + ], + "agent.version": [ + "8.0.0-SNAPSHOT" + ], + "host.os.family": [ + "debian" + ], + "destination.port": [ + 9200 + ], + "group.id": [ + "0" + ], + "user.name": [ + "root" + ], + "source.address": [ + "127.0.0.1" + ], + "process.entity_id": [ + "MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA=" + ], + "host.ip": [ + "127.0.0.1", + "::1", + "10.1.2.3", + "2001:0DB8:AC10:FE01::" + ], + "process.executable.caseless": [ + "/opt/elastic/agent/data/elastic-agent-058c40/install/filebeat-8.0.0-snapshot-linux-x86_64/filebeat" + ], + "event.sequence": [ + 44872 + ], + "agent.type": [ + "endpoint" + ], + "process.executable.text": [ + "/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat" + ], + "group.Ext.real.name": [ + "root" + ], + "event.module": [ + "endpoint" + ], + "host.os.kernel": [ + "4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)" + ], + "host.os.full.caseless": [ + "debian 10" + ], + "host.id": [ + "76ea303129f249aa7382338e4263eac1" + ], + "process.name.caseless": [ + "filebeat" + ], + "network.type": [ + "ipv4" + ], + "process.executable": [ + "/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat" + ], + "user.Ext.real.name": [ + "root" + ], + "data_stream.namespace": [ + "default" + ], + "message": [ + "Endpoint network event" + ], + "destination.ip": [ + "127.0.0.1" + ], + "network.transport": [ + "tcp" + ], + "host.os.Ext.variant": [ + "Debian" + ], + "group.Ext.real.id": [ + "0" + ], + "event.ingested": [ + "2021-10-14T16:46:04.000Z" + ], + "event.action": [ + "connection_attempted" + ], + "@timestamp": [ + "2021-10-14T16:45:58.031Z" + ], + "host.os.platform": [ + "debian" + ], + "data_stream.dataset": [ + "endpoint.events.network" + ], + "event.type": [ + "start" + ], + "event.id": [ + "MKPXftjGeHiQzUNj++++nn6R" + ], + "host.os.name.caseless": [ + "linux" + ], + "event.dataset": [ + "endpoint.events.network" + ], + "user.name.text": [ + "root" + ] } } diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index a8ba536a75541..37ca3b0b897a6 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; -import { mockDetailItemData, mockDetailItemDataId, TestProviders } from '../../mock'; +import { mockDetailItemData, mockDetailItemDataId, rawEventData, TestProviders } from '../../mock'; import { EventDetails, EventsViewType } from './event_details'; import { mockBrowserFields } from '../../containers/source/mock'; @@ -48,6 +48,7 @@ describe('EventDetails', () => { timelineId: 'test', eventView: EventsViewType.summaryView, hostRisk: { fields: [], loading: true }, + rawEventData, }; const alertsProps = { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index e7092d9d6f466..a8305a635f157 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -61,6 +61,7 @@ interface Props { id: string; isAlert: boolean; isDraggable?: boolean; + rawEventData: object | undefined; timelineTabType: TimelineTabs | 'flyout'; timelineId: string; hostRisk: HostRisk | null; @@ -106,6 +107,7 @@ const EventDetailsComponent: React.FC = ({ id, isAlert, isDraggable, + rawEventData, timelineId, timelineTabType, hostRisk, @@ -278,12 +280,12 @@ const EventDetailsComponent: React.FC = ({ <> - + ), }), - [data] + [rawEventData] ); const tabs = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx index 696fac6016603..b20270266602d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx @@ -8,58 +8,15 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { mockDetailItemData } from '../../mock'; +import { rawEventData } from '../../mock'; -import { buildJsonView, JsonView } from './json_view'; +import { JsonView } from './json_view'; describe('JSON View', () => { describe('rendering', () => { test('should match snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); }); - - describe('buildJsonView', () => { - test('should match a json', () => { - const expectedData = { - '@timestamp': '2019-02-28T16:50:54.621Z', - _id: 'pEMaMmkBUV60JmNWmWVi', - _index: 'filebeat-8.0.0-2019.02.19-000001', - _score: 1, - _type: '_doc', - agent: { - ephemeral_id: '9d391ef2-a734-4787-8891-67031178c641', - hostname: 'siem-kibana', - id: '5de03d5f-52f3-482e-91d4-853c7de073c3', - type: 'filebeat', - version: '8.0.0', - }, - cloud: { - availability_zone: 'projects/189716325846/zones/us-east1-b', - instance: { - id: '5412578377715150143', - name: 'siem-kibana', - }, - machine: { - type: 'projects/189716325846/machineTypes/n1-standard-1', - }, - project: { - id: 'elastic-beats', - }, - provider: 'gce', - }, - destination: { - bytes: 584, - ip: '10.47.8.200', - packets: 4, - port: 902, - }, - event: { - kind: 'event', - }, - }; - expect(buildJsonView(mockDetailItemData)).toEqual(expectedData); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx index 0614f131bcd10..0227d44f32305 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx @@ -6,15 +6,13 @@ */ import { EuiCodeBlock } from '@elastic/eui'; -import { set } from '@elastic/safer-lodash-set/fp'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { omitTypenameAndEmpty } from '../../../timelines/components/timeline/body/helpers'; interface Props { - data: TimelineEventsDetailsItem[]; + rawEventData: object | undefined; } const EuiCodeEditorContainer = styled.div` @@ -23,15 +21,15 @@ const EuiCodeEditorContainer = styled.div` } `; -export const JsonView = React.memo(({ data }) => { +export const JsonView = React.memo(({ rawEventData }) => { const value = useMemo( () => JSON.stringify( - buildJsonView(data), + rawEventData, omitTypenameAndEmpty, 2 // indent level ), - [data] + [rawEventData] ); return ( @@ -50,16 +48,3 @@ export const JsonView = React.memo(({ data }) => { }); JsonView.displayName = 'JsonView'; - -export const buildJsonView = (data: TimelineEventsDetailsItem[]) => - data - .sort((a, b) => a.field.localeCompare(b.field)) - .reduce( - (accumulator, item) => - set( - item.field, - Array.isArray(item.originalValue) ? item.originalValue.join() : item.originalValue, - accumulator - ), - {} - ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx new file mode 100644 index 0000000000000..f6c43da2da8ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.test.tsx @@ -0,0 +1,193 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { BrowserField } from '../../../containers/source'; +import { FieldValueCell } from './field_value_cell'; +import { TestProviders } from '../../../mock'; +import { EventFieldsData } from '../types'; + +const contextId = 'test'; + +const eventId = 'TUWyf3wBFCFU0qRJTauW'; + +const hostIpData: EventFieldsData = { + aggregatable: true, + ariaRowindex: 35, + category: 'host', + description: 'Host ip addresses.', + example: '127.0.0.1', + field: 'host.ip', + fields: {}, + format: '', + indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + isObjectArray: false, + name: 'host.ip', + originalValue: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'], + searchable: true, + type: 'ip', + values: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'], +}; +const hostIpValues = ['127.0.0.1', '::1', '10.1.2.3', 'fe80::4001:aff:fec8:32']; + +describe('FieldValueCell', () => { + describe('common behavior', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it formats multiple values such that each value is displayed on a single line', () => { + expect(screen.getByTestId(`event-field-${hostIpData.field}`)).toHaveClass( + 'euiFlexGroup--directionColumn' + ); + }); + }); + + describe('when `BrowserField` metadata is NOT available', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it renders each of the expected values when `fieldFromBrowserField` is undefined', () => { + hostIpValues.forEach((value) => { + expect(screen.getByText(value)).toBeInTheDocument(); + }); + }); + + test('it renders values formatted as plain text (without `eventFieldsTable__fieldValue` formatting)', () => { + expect(screen.getByTestId(`event-field-${hostIpData.field}`).firstChild).not.toHaveClass( + 'eventFieldsTable__fieldValue' + ); + }); + }); + + describe('`message` field formatting', () => { + const messageData: EventFieldsData = { + aggregatable: false, + ariaRowindex: 50, + category: 'base', + description: + 'For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.', + example: 'Hello World', + field: 'message', + fields: {}, + format: '', + indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + isObjectArray: false, + name: 'message', + originalValue: ['Endpoint network event'], + searchable: true, + type: 'string', + values: ['Endpoint network event'], + }; + const messageValues = ['Endpoint network event']; + + const messageFieldFromBrowserField: BrowserField = { + aggregatable: false, + category: 'base', + description: + 'For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.', + example: 'Hello World', + fields: {}, + format: '', + indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + name: 'message', + searchable: true, + type: 'string', + }; + + beforeEach(() => { + render( + + + + ); + }); + + test('it renders special formatting for the `message` field', () => { + expect(screen.getByTestId('event-field-message')).toBeInTheDocument(); + }); + + test('it renders the expected message value', () => { + messageValues.forEach((value) => { + expect(screen.getByText(value)).toBeInTheDocument(); + }); + }); + }); + + describe('when `BrowserField` metadata IS available', () => { + const hostIpFieldFromBrowserField: BrowserField = { + aggregatable: true, + category: 'host', + description: 'Host ip addresses.', + example: '127.0.0.1', + fields: {}, + format: '', + indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'], + name: 'host.ip', + searchable: true, + type: 'ip', + }; + + beforeEach(() => { + render( + + + + ); + }); + + test('it renders values formatted with the expected class', () => { + expect(screen.getByTestId(`event-field-${hostIpData.field}`).firstChild).toHaveClass( + 'eventFieldsTable__fieldValue' + ); + }); + + test('it renders link buttons for each of the host ip addresses', () => { + expect(screen.getAllByRole('button').length).toBe(hostIpValues.length); + }); + + test('it renders each of the expected values when `fieldFromBrowserField` is provided', () => { + hostIpValues.forEach((value) => { + expect(screen.getByText(value)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx index fc20f84d3650d..dc6c84b8138fe 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { BrowserField } from '../../../containers/source'; import { OverflowField } from '../../tables/helpers'; import { FormattedFieldValue } from '../../../../timelines/components/timeline/body/renderers/formatted_field'; @@ -36,18 +36,28 @@ export const FieldValueCell = React.memo( values, }: FieldValueCellProps) => { return ( -
+ {values != null && values.map((value, i) => { if (fieldFromBrowserField == null) { return ( - - {value} - + + + {value} + + ); } return ( -
+ {data.field === MESSAGE_FIELD_NAME ? ( ) : ( @@ -63,10 +73,10 @@ export const FieldValueCell = React.memo( linkValue={(getLinkValue && getLinkValue(data.field)) ?? linkValue} /> )} -
+ ); })} -
+ ); } ); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index.tsx index 281db88ebd057..2e04bbc5f1daf 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index.tsx @@ -25,6 +25,7 @@ export const UseUrlStateMemo = React.memo( prevProps.pathName === nextProps.pathName && deepEqual(prevProps.urlState, nextProps.urlState) && deepEqual(prevProps.indexPattern, nextProps.indexPattern) && + prevProps.search === nextProps.search && deepEqual(prevProps.navTabs, nextProps.navTabs) ); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx index 4a448e9064090..f04cf30da61f5 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx @@ -124,7 +124,7 @@ export const useSetInitialStateFromUrl = () => { [dispatch, updateTimeline, updateTimelineIsLoading] ); - return setInitialStateFromUrl; + return Object.freeze({ setInitialStateFromUrl, updateTimeline, updateTimelineIsLoading }); }; const updateTimerange = (newUrlStateString: string, dispatch: Dispatch) => { diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/query_timeline_by_id_on_url_change.test.ts b/x-pack/plugins/security_solution/public/common/components/url_state/query_timeline_by_id_on_url_change.test.ts new file mode 100644 index 0000000000000..5cc4f8e8b80f9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/url_state/query_timeline_by_id_on_url_change.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { queryTimelineById } from '../../../timelines/components/open_timeline/helpers'; +import { queryTimelineByIdOnUrlChange } from './query_timeline_by_id_on_url_change'; +import * as urlHelpers from './helpers'; + +jest.mock('../../../timelines/components/open_timeline/helpers'); + +describe('queryTimelineByIdOnUrlChange', () => { + const oldTestTimelineId = '04e8ffb0-2c2a-11ec-949c-39005af91f70'; + const newTestTimelineId = `${oldTestTimelineId}-newId`; + const oldTimelineRisonSearchString = `?timeline=(activeTab:query,graphEventId:%27%27,id:%27${oldTestTimelineId}%27,isOpen:!t)`; + const newTimelineRisonSearchString = `?timeline=(activeTab:query,graphEventId:%27%27,id:%27${newTestTimelineId}%27,isOpen:!t)`; + const mockUpdateTimeline = jest.fn(); + const mockUpdateTimelineIsLoading = jest.fn(); + const mockQueryTimelineById = jest.fn(); + beforeEach(() => { + (queryTimelineById as jest.Mock).mockImplementation(mockQueryTimelineById); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when search strings are empty', () => { + it('should not call queryTimelineById', () => { + queryTimelineByIdOnUrlChange({ + oldSearch: '', + search: '', + timelineIdFromReduxStore: 'current-timeline-id', + updateTimeline: mockUpdateTimeline, + updateTimelineIsLoading: mockUpdateTimelineIsLoading, + }); + + expect(queryTimelineById).not.toBeCalled(); + }); + }); + + describe('when search string has not changed', () => { + it('should not call queryTimelineById', () => { + queryTimelineByIdOnUrlChange({ + oldSearch: oldTimelineRisonSearchString, + search: oldTimelineRisonSearchString, + timelineIdFromReduxStore: 'timeline-id', + updateTimeline: mockUpdateTimeline, + updateTimelineIsLoading: mockUpdateTimelineIsLoading, + }); + + expect(queryTimelineById).not.toBeCalled(); + }); + }); + + describe('when decode rison fails', () => { + it('should not call queryTimelineById', () => { + jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementationOnce(() => { + throw new Error('Unable to decode'); + }); + + queryTimelineByIdOnUrlChange({ + oldSearch: oldTimelineRisonSearchString, + search: newTimelineRisonSearchString, + timelineIdFromReduxStore: '', + updateTimeline: mockUpdateTimeline, + updateTimelineIsLoading: mockUpdateTimelineIsLoading, + }); + + expect(queryTimelineById).not.toBeCalled(); + }); + }); + + describe('when new id is not provided', () => { + it('should not call queryTimelineById', () => { + queryTimelineByIdOnUrlChange({ + oldSearch: oldTimelineRisonSearchString, + search: '?timeline=(activeTab:query)', // no id + timelineIdFromReduxStore: newTestTimelineId, + updateTimeline: mockUpdateTimeline, + updateTimelineIsLoading: mockUpdateTimelineIsLoading, + }); + + expect(queryTimelineById).not.toBeCalled(); + }); + }); + + describe('when new id matches the data in redux', () => { + it('should not call queryTimelineById', () => { + queryTimelineByIdOnUrlChange({ + oldSearch: oldTimelineRisonSearchString, + search: newTimelineRisonSearchString, + timelineIdFromReduxStore: newTestTimelineId, + updateTimeline: mockUpdateTimeline, + updateTimelineIsLoading: mockUpdateTimelineIsLoading, + }); + + expect(queryTimelineById).not.toBeCalled(); + }); + }); + + // You can only redirect or run into conflict scenarios when already viewing a timeline + describe('when not actively on a page with timeline in the search field', () => { + it('should not call queryTimelineById', () => { + queryTimelineByIdOnUrlChange({ + oldSearch: '?random=foo', + search: newTimelineRisonSearchString, + timelineIdFromReduxStore: oldTestTimelineId, + updateTimeline: mockUpdateTimeline, + updateTimelineIsLoading: mockUpdateTimelineIsLoading, + }); + + expect(queryTimelineById).not.toBeCalled(); + }); + }); + + describe('when an old timeline id exists, but a new id is given', () => { + it('should call queryTimelineById', () => { + queryTimelineByIdOnUrlChange({ + oldSearch: oldTimelineRisonSearchString, + search: newTimelineRisonSearchString, + timelineIdFromReduxStore: oldTestTimelineId, + updateTimeline: mockUpdateTimeline, + updateTimelineIsLoading: mockUpdateTimelineIsLoading, + }); + + expect(queryTimelineById).toBeCalledWith({ + activeTimelineTab: 'query', + duplicate: false, + graphEventId: '', + timelineId: newTestTimelineId, + openTimeline: true, + updateIsLoading: mockUpdateTimelineIsLoading, + updateTimeline: mockUpdateTimeline, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/query_timeline_by_id_on_url_change.ts b/x-pack/plugins/security_solution/public/common/components/url_state/query_timeline_by_id_on_url_change.ts new file mode 100644 index 0000000000000..2778cefdc7953 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/url_state/query_timeline_by_id_on_url_change.ts @@ -0,0 +1,84 @@ +/* + * 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 { Action } from 'typescript-fsa'; +import { DispatchUpdateTimeline } from '../../../timelines/components/open_timeline/types'; +import { queryTimelineById } from '../../../timelines/components/open_timeline/helpers'; +import { TimelineTabs } from '../../../../common/types/timeline'; +import { + decodeRisonUrlState, + getQueryStringFromLocation, + getParamFromQueryString, +} from './helpers'; +import { TimelineUrl } from '../../../timelines/store/timeline/model'; +import { CONSTANTS } from './constants'; + +const getQueryStringKeyValue = ({ search, urlKey }: { search: string; urlKey: string }) => + getParamFromQueryString(getQueryStringFromLocation(search), urlKey); + +interface QueryTimelineIdOnUrlChange { + oldSearch?: string; + search: string; + timelineIdFromReduxStore: string; + updateTimeline: DispatchUpdateTimeline; + updateTimelineIsLoading: (status: { id: string; isLoading: boolean }) => Action<{ + id: string; + isLoading: boolean; + }>; +} + +/** + * After the initial load of the security solution, timeline is not updated when the timeline url search value is changed + * This is because those state changes happen in place and doesn't lead to a requerying of data for the new id. + * To circumvent this for the sake of the redirects needed for the saved object Id changes happening in 8.0 + * We are actively pulling the id changes that take place for timeline in the url and calling the query below + * to request the new data. + */ +export const queryTimelineByIdOnUrlChange = ({ + oldSearch, + search, + timelineIdFromReduxStore, + updateTimeline, + updateTimelineIsLoading, +}: QueryTimelineIdOnUrlChange) => { + const oldUrlStateString = getQueryStringKeyValue({ + urlKey: CONSTANTS.timeline, + search: oldSearch ?? '', + }); + + const newUrlStateString = getQueryStringKeyValue({ urlKey: CONSTANTS.timeline, search }); + + if (oldUrlStateString != null && newUrlStateString != null) { + let newTimeline = null; + let oldTimeline = null; + try { + newTimeline = decodeRisonUrlState(newUrlStateString); + } catch (error) { + // do nothing as timeline is defaulted to null + } + + try { + oldTimeline = decodeRisonUrlState(oldUrlStateString); + } catch (error) { + // do nothing as timeline is defaulted to null + } + const newId = newTimeline?.id; + const oldId = oldTimeline?.id; + + if (newId && newId !== oldId && newId !== timelineIdFromReduxStore) { + queryTimelineById({ + activeTimelineTab: newTimeline?.activeTab ?? TimelineTabs.query, + duplicate: false, + graphEventId: newTimeline?.graphEventId, + timelineId: newId, + openTimeline: true, + updateIsLoading: updateTimelineIsLoading, + updateTimeline, + }); + } + } +}; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts index e803c091423be..06ed33ac69f6e 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts @@ -79,6 +79,7 @@ export interface PreviousLocationUrlState { pathName: string | undefined; pageName: string | undefined; urlState: UrlState; + search: string | undefined; } export interface UrlStateToRedux { diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx index bc47ba9d8ae99..3245d647227ad 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx @@ -39,6 +39,7 @@ import { } from './types'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; import { UrlInputsModel } from '../../store/inputs/model'; +import { queryTimelineByIdOnUrlChange } from './query_timeline_by_id_on_url_change'; function usePrevious(value: PreviousLocationUrlState) { const ref = useRef(value); @@ -60,9 +61,10 @@ export const useUrlStateHooks = ({ const [isFirstPageLoad, setIsFirstPageLoad] = useState(true); const { filterManager, savedQueries } = useKibana().services.data.query; const { pathname: browserPathName } = useLocation(); - const prevProps = usePrevious({ pathName, pageName, urlState }); + const prevProps = usePrevious({ pathName, pageName, urlState, search }); - const setInitialStateFromUrl = useSetInitialStateFromUrl(); + const { setInitialStateFromUrl, updateTimeline, updateTimelineIsLoading } = + useSetInitialStateFromUrl(); const handleInitialize = useCallback( (type: UrlStateType) => { @@ -190,6 +192,16 @@ export const useUrlStateHooks = ({ document.title = `${getTitle(pageName, navTabs)} - Kibana`; }, [pageName, navTabs]); + useEffect(() => { + queryTimelineByIdOnUrlChange({ + oldSearch: prevProps.search, + search, + timelineIdFromReduxStore: urlState.timeline.id, + updateTimeline, + updateTimelineIsLoading, + }); + }, [search, prevProps.search, urlState.timeline.id, updateTimeline, updateTimelineIsLoading]); + return null; }; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.test.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.test.tsx new file mode 100644 index 0000000000000..bafbe078cdbdb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.test.tsx @@ -0,0 +1,166 @@ +/* + * 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 { useLocation } from 'react-router-dom'; +import { renderHook } from '@testing-library/react-hooks'; +import { useDeepEqualSelector } from './use_selector'; +import { useKibana } from '../lib/kibana'; +import { useResolveConflict } from './use_resolve_conflict'; +import * as urlHelpers from '../components/url_state/helpers'; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + + return { + ...original, + useLocation: jest.fn(), + }; +}); +jest.mock('../lib/kibana'); +jest.mock('./use_selector'); +jest.mock('../../timelines/store/timeline/', () => ({ + timelineSelectors: { + getTimelineByIdSelector: () => jest.fn(), + }, +})); + +describe('useResolveConflict', () => { + const mockGetLegacyUrlConflict = jest.fn().mockReturnValue('Test!'); + beforeEach(() => { + jest.resetAllMocks(); + // Mock rison format in actual url + (useLocation as jest.Mock).mockReturnValue({ + pathname: 'my/cool/path', + search: + 'timeline=(activeTab:query,graphEventId:%27%27,id:%2704e8ffb0-2c2a-11ec-949c-39005af91f70%27,isOpen:!t)', + }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + spaces: { + ui: { + components: { + getLegacyUrlConflict: mockGetLegacyUrlConflict, + }, + }, + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('resolve object is not provided', () => { + it('should not show the conflict message', async () => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + savedObjectId: 'current-saved-object-id', + activeTab: 'some-tab', + graphEventId: 'current-graph-event-id', + show: false, + })); + const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict()); + expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled(); + expect(result.current).toEqual(null); + }); + }); + + describe('outcome is exactMatch', () => { + it('should not show the conflict message', async () => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + resolveTimelineConfig: { + outcome: 'exactMatch', + }, + savedObjectId: 'current-saved-object-id', + activeTab: 'some-tab', + graphEventId: 'current-graph-event-id', + show: false, + })); + const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict()); + expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled(); + expect(result.current).toEqual(null); + }); + }); + + describe('outcome is aliasMatch', () => { + it('should not show the conflict message', async () => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + resolveTimelineConfig: { + outcome: 'aliasMatch', + alias_target_id: 'new-id', + }, + })); + const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict()); + expect(mockGetLegacyUrlConflict).not.toHaveBeenCalled(); + expect(result.current).toEqual(null); + }); + }); + + describe('outcome is conflict', () => { + const mockTextContent = 'I am the visible conflict message'; + it('should show the conflict message', async () => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + resolveTimelineConfig: { + outcome: 'conflict', + alias_target_id: 'new-id', + }, + })); + mockGetLegacyUrlConflict.mockImplementation(() => mockTextContent); + const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict()); + expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({ + objectNoun: 'timeline', + currentObjectId: '04e8ffb0-2c2a-11ec-949c-39005af91f70', + otherObjectId: 'new-id', + otherObjectPath: + 'my/cool/path?timeline=%28activeTab%3Aquery%2CgraphEventId%3A%27%27%2Cid%3Anew-id%2CisOpen%3A%21t%29', + }); + expect(result.current).toMatchInlineSnapshot(` + + I am the visible conflict message + + + `); + }); + + describe('rison is unable to be decoded', () => { + it('should use timeline values from redux to create the otherObjectPath', async () => { + jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementation(() => { + throw new Error('Unable to decode'); + }); + (useLocation as jest.Mock).mockReturnValue({ + pathname: 'my/cool/path', + search: '?foo=bar', + }); + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + resolveTimelineConfig: { + outcome: 'conflict', + alias_target_id: 'new-id', + }, + savedObjectId: 'current-saved-object-id', + activeTab: 'some-tab', + graphEventId: 'current-graph-event-id', + show: false, + })); + mockGetLegacyUrlConflict.mockImplementation(() => mockTextContent); + renderHook(() => useResolveConflict()); + const { result } = renderHook<{}, JSX.Element | null>(() => useResolveConflict()); + expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({ + objectNoun: 'timeline', + currentObjectId: 'current-saved-object-id', + otherObjectId: 'new-id', + otherObjectPath: + 'my/cool/path?foo=bar&timeline=%28activeTab%3Asome-tab%2CgraphEventId%3Acurrent-graph-event-id%2Cid%3Anew-id%2CisOpen%3A%21f%29', + }); + expect(result.current).toMatchInlineSnapshot(` + + I am the visible conflict message + + + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.tsx new file mode 100644 index 0000000000000..6a493d944ecda --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_conflict.tsx @@ -0,0 +1,100 @@ +/* + * 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 { useLocation } from 'react-router-dom'; +import { EuiSpacer } from '@elastic/eui'; +import { useDeepEqualSelector } from './use_selector'; +import { TimelineId } from '../../../common/types/timeline'; +import { timelineSelectors } from '../../timelines/store/timeline'; +import { TimelineUrl } from '../../timelines/store/timeline/model'; +import { timelineDefaults } from '../../timelines/store/timeline/defaults'; +import { decodeRisonUrlState, encodeRisonUrlState } from '../components/url_state/helpers'; +import { useKibana } from '../lib/kibana'; +import { CONSTANTS } from '../components/url_state/constants'; + +/** + * Unfortunately the url change initiated when clicking the button to otherObjectPath doesn't seem to be + * respected by the useSetInitialStateFromUrl here: x-pack/plugins/security_solution/public/common/components/url_state/initialize_redux_by_url.tsx + * + * FYI: It looks like the routing causes replaceStateInLocation to be called instead: + * x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts + * + * Potentially why the markdown component needs a click handler as well for timeline? + * see: /x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx + */ +export const useResolveConflict = () => { + const { search, pathname } = useLocation(); + const { spaces } = useKibana().services; + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { resolveTimelineConfig, savedObjectId, show, graphEventId, activeTab } = + useDeepEqualSelector((state) => getTimeline(state, TimelineId.active) ?? timelineDefaults); + + const getLegacyUrlConflictCallout = useCallback(() => { + // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario + if ( + !spaces || + resolveTimelineConfig?.outcome !== 'conflict' || + resolveTimelineConfig?.alias_target_id == null + ) { + return null; + } + + const searchQuery = new URLSearchParams(search); + const timelineRison = searchQuery.get(CONSTANTS.timeline) ?? undefined; + // Try to get state on URL, but default to what's in Redux in case of decodeRisonFailure + const currentTimelineState = { + id: savedObjectId ?? '', + isOpen: !!show, + activeTab, + graphEventId, + }; + let timelineSearch: TimelineUrl = currentTimelineState; + try { + timelineSearch = decodeRisonUrlState(timelineRison) ?? currentTimelineState; + } catch (error) { + // do nothing as it's already defaulted on line 77 + } + // We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a + // callout with a warning for the user, and provide a way for them to navigate to the other object. + const currentObjectId = timelineSearch?.id; + const newSavedObjectId = resolveTimelineConfig?.alias_target_id ?? ''; // This is always defined if outcome === 'conflict' + + const newTimelineSearch: TimelineUrl = { + ...timelineSearch, + id: newSavedObjectId, + }; + const newTimelineRison = encodeRisonUrlState(newTimelineSearch); + searchQuery.set(CONSTANTS.timeline, newTimelineRison); + + const newPath = `${pathname}?${searchQuery.toString()}${window.location.hash}`; + + return ( + <> + {spaces.ui.components.getLegacyUrlConflict({ + objectNoun: CONSTANTS.timeline, + currentObjectId, + otherObjectId: newSavedObjectId, + otherObjectPath: newPath, + })} + + + ); + }, [ + activeTab, + graphEventId, + pathname, + resolveTimelineConfig?.alias_target_id, + resolveTimelineConfig?.outcome, + savedObjectId, + search, + show, + spaces, + ]); + + return useMemo(() => getLegacyUrlConflictCallout(), [getLegacyUrlConflictCallout]); +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts new file mode 100644 index 0000000000000..c9a0eedefd0af --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { useLocation } from 'react-router-dom'; +import { renderHook } from '@testing-library/react-hooks'; +import { useDeepEqualSelector } from './use_selector'; +import { useKibana } from '../lib/kibana'; +import { useResolveRedirect } from './use_resolve_redirect'; +import * as urlHelpers from '../components/url_state/helpers'; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + + return { + ...original, + useLocation: jest.fn(), + }; +}); +jest.mock('../lib/kibana'); +jest.mock('./use_selector'); +jest.mock('../../timelines/store/timeline/', () => ({ + timelineSelectors: { + getTimelineByIdSelector: () => jest.fn(), + }, +})); + +describe('useResolveRedirect', () => { + const mockRedirectLegacyUrl = jest.fn(); + beforeEach(() => { + jest.resetAllMocks(); + // Mock rison format in actual url + (useLocation as jest.Mock).mockReturnValue({ + pathname: 'my/cool/path', + search: + 'timeline=(activeTab:query,graphEventId:%27%27,id:%2704e8ffb0-2c2a-11ec-949c-39005af91f70%27,isOpen:!t)', + }); + (useKibana as jest.Mock).mockReturnValue({ + services: { + spaces: { + ui: { + redirectLegacyUrl: mockRedirectLegacyUrl, + }, + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('resolve object is not provided', () => { + it('should not redirect', async () => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + savedObjectId: 'current-saved-object-id', + activeTab: 'some-tab', + graphEventId: 'current-graph-event-id', + show: false, + })); + renderHook(() => useResolveRedirect()); + expect(mockRedirectLegacyUrl).not.toHaveBeenCalled(); + }); + }); + + describe('outcome is exactMatch', () => { + it('should not redirect', async () => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + resolveTimelineConfig: { + outcome: 'exactMatch', + }, + savedObjectId: 'current-saved-object-id', + activeTab: 'some-tab', + graphEventId: 'current-graph-event-id', + show: false, + })); + renderHook(() => useResolveRedirect()); + expect(mockRedirectLegacyUrl).not.toHaveBeenCalled(); + }); + }); + + describe('outcome is aliasMatch', () => { + it('should redirect to url with id:new-id if outcome is aliasMatch', async () => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + resolveTimelineConfig: { + outcome: 'aliasMatch', + alias_target_id: 'new-id', + }, + })); + renderHook(() => useResolveRedirect()); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith( + 'my/cool/path?timeline=%28activeTab%3Aquery%2CgraphEventId%3A%27%27%2Cid%3Anew-id%2CisOpen%3A%21t%29', + 'timeline' + ); + }); + + describe('rison is unable to be decoded', () => { + it('should use timeline values from redux to create the redirect path', async () => { + jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementation(() => { + throw new Error('Unable to decode'); + }); + (useLocation as jest.Mock).mockReturnValue({ + pathname: 'my/cool/path', + search: '?foo=bar', + }); + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + resolveTimelineConfig: { + outcome: 'aliasMatch', + alias_target_id: 'new-id', + }, + savedObjectId: 'current-saved-object-id', + activeTab: 'some-tab', + graphEventId: 'current-graph-event-id', + show: false, + })); + renderHook(() => useResolveRedirect()); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith( + 'my/cool/path?foo=bar&timeline=%28activeTab%3Asome-tab%2CgraphEventId%3Acurrent-graph-event-id%2Cid%3Anew-id%2CisOpen%3A%21f%29', + 'timeline' + ); + }); + }); + }); + + describe('outcome is conflict', () => { + it('should not redirect', async () => { + (useDeepEqualSelector as jest.Mock).mockImplementation(() => ({ + resolveTimelineConfig: { + outcome: 'conflict', + alias_target_id: 'new-id', + }, + })); + renderHook(() => useResolveRedirect()); + expect(mockRedirectLegacyUrl).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.ts b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.ts new file mode 100644 index 0000000000000..a6ba0b24828e7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_resolve_redirect.ts @@ -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 { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useDeepEqualSelector } from './use_selector'; +import { TimelineId } from '../../../common/types/timeline'; +import { timelineSelectors } from '../../timelines/store/timeline/'; +import { timelineDefaults } from '../../timelines/store/timeline/defaults'; +import { decodeRisonUrlState, encodeRisonUrlState } from '../components/url_state/helpers'; +import { useKibana } from '../lib/kibana'; +import { TimelineUrl } from '../../timelines/store/timeline/model'; +import { CONSTANTS } from '../components/url_state/constants'; + +/** + * This hooks is specifically for use with the resolve api that was introduced as part of 7.16 + * If a deep link id has been migrated to a new id, this hook will cause a redirect to a url with + * the new ID. + */ + +export const useResolveRedirect = () => { + const { search, pathname } = useLocation(); + const [hasRedirected, updateHasRedirected] = useState(false); + const { spaces } = useKibana().services; + + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const { resolveTimelineConfig, savedObjectId, show, activeTab, graphEventId } = + useDeepEqualSelector((state) => getTimeline(state, TimelineId.active) ?? timelineDefaults); + + const redirect = useCallback(() => { + const searchQuery = new URLSearchParams(search); + const timelineRison = searchQuery.get(CONSTANTS.timeline) ?? undefined; + + // Try to get state on URL, but default to what's in Redux in case of decodeRisonFailure + const currentTimelineState = { + id: savedObjectId ?? '', + isOpen: !!show, + activeTab, + graphEventId, + }; + let timelineSearch: TimelineUrl = currentTimelineState; + try { + timelineSearch = decodeRisonUrlState(timelineRison) ?? currentTimelineState; + } catch (error) { + // do nothing as it's already defaulted on line 77 + } + + if ( + hasRedirected || + !spaces || + resolveTimelineConfig?.outcome !== 'aliasMatch' || + resolveTimelineConfig?.alias_target_id == null + ) { + return null; + } + + // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash + const newObjectId = resolveTimelineConfig?.alias_target_id ?? ''; // This is always defined if outcome === 'aliasMatch' + const newTimelineSearch = { + ...timelineSearch, + id: newObjectId, + }; + const newTimelineRison = encodeRisonUrlState(newTimelineSearch); + searchQuery.set(CONSTANTS.timeline, newTimelineRison); + const newPath = `${pathname}?${searchQuery.toString()}`; + spaces.ui.redirectLegacyUrl(newPath, CONSTANTS.timeline); + // Prevent the effect from being called again as the url change takes place in location rather than a true redirect + updateHasRedirected(true); + }, [ + activeTab, + graphEventId, + hasRedirected, + pathname, + resolveTimelineConfig?.outcome, + resolveTimelineConfig?.alias_target_id, + savedObjectId, + search, + show, + spaces, + ]); + + useEffect(() => { + redirect(); + }, [redirect]); +}; diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts b/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts index 3712d389edeb1..035bdbbceff88 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts +++ b/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts @@ -139,3 +139,191 @@ export const generateMockDetailItemData = (): TimelineEventsDetailsItem[] => [ ]; export const mockDetailItemData: TimelineEventsDetailsItem[] = generateMockDetailItemData(); + +export const rawEventData = { + _index: '.ds-logs-endpoint.events.network-default-2021.09.28-000001', + _id: 'TUWyf3wBFCFU0qRJTauW', + _score: 1, + _source: { + agent: { + id: '2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624', + type: 'endpoint', + version: '8.0.0-SNAPSHOT', + }, + process: { + Ext: { + ancestry: [ + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=', + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA==', + ], + }, + name: 'filebeat', + pid: 22535, + entity_id: 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA=', + executable: + '/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat', + }, + destination: { + address: '127.0.0.1', + port: 9200, + ip: '127.0.0.1', + }, + source: { + address: '127.0.0.1', + port: 54146, + ip: '127.0.0.1', + }, + message: 'Endpoint network event', + network: { + transport: 'tcp', + type: 'ipv4', + }, + '@timestamp': '2021-10-14T16:45:58.0310772Z', + ecs: { + version: '1.11.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.network', + }, + elastic: { + agent: { + id: '12345', + }, + }, + host: { + hostname: 'test-linux-1', + os: { + Ext: { + variant: 'Debian', + }, + kernel: '4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)', + name: 'Linux', + family: 'debian', + type: 'linux', + version: '10', + platform: 'debian', + full: 'Debian 10', + }, + ip: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'], + name: 'test-linux-1', + id: '76ea303129f249aa7382338e4263eac1', + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: 'x86_64', + }, + event: { + agent_id_status: 'verified', + sequence: 44872, + ingested: '2021-10-14T16:46:04Z', + created: '2021-10-14T16:45:58.0310772Z', + kind: 'event', + module: 'endpoint', + action: 'connection_attempted', + id: 'MKPXftjGeHiQzUNj++++nn6R', + category: ['network'], + type: ['start'], + dataset: 'endpoint.events.network', + outcome: 'unknown', + }, + user: { + Ext: { + real: { + name: 'root', + id: 0, + }, + }, + name: 'root', + id: 0, + }, + group: { + Ext: { + real: { + name: 'root', + id: 0, + }, + }, + name: 'root', + id: 0, + }, + }, + fields: { + 'host.os.full.text': ['Debian 10'], + 'event.category': ['network'], + 'process.name.text': ['filebeat'], + 'host.os.name.text': ['Linux'], + 'host.os.full': ['Debian 10'], + 'host.hostname': ['test-linux-1'], + 'process.pid': [22535], + 'host.mac': ['42:01:0a:c8:00:32'], + 'elastic.agent.id': ['abcdefg-f6d5-4ce6-915d-8f1f8f413624'], + 'host.os.version': ['10'], + 'host.os.name': ['Linux'], + 'source.ip': ['127.0.0.1'], + 'destination.address': ['127.0.0.1'], + 'host.name': ['test-linux-1'], + 'event.agent_id_status': ['verified'], + 'event.kind': ['event'], + 'event.outcome': ['unknown'], + 'group.name': ['root'], + 'user.id': ['0'], + 'host.os.type': ['linux'], + 'process.Ext.ancestry': [ + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyMzY0LTEzMjc4NjA2NTAyLjA=', + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTEtMTMyNzA3Njg2OTIuMA==', + ], + 'user.Ext.real.id': ['0'], + 'data_stream.type': ['logs'], + 'host.architecture': ['x86_64'], + 'process.name': ['filebeat'], + 'agent.id': ['2ac9e9b3-f6d5-4ce6-915d-8f1f8f413624'], + 'source.port': [54146], + 'ecs.version': ['1.11.0'], + 'event.created': ['2021-10-14T16:45:58.031Z'], + 'agent.version': ['8.0.0-SNAPSHOT'], + 'host.os.family': ['debian'], + 'destination.port': [9200], + 'group.id': ['0'], + 'user.name': ['root'], + 'source.address': ['127.0.0.1'], + 'process.entity_id': [ + 'MmFjOWU5YjMtZjZkNS00Y2U2LTkxNWQtOGYxZjhmNDEzNjI0LTIyNTM1LTEzMjc4NjA2NTI4LjA=', + ], + 'host.ip': ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'], + 'process.executable.caseless': [ + '/opt/elastic/agent/data/elastic-agent-058c40/install/filebeat-8.0.0-snapshot-linux-x86_64/filebeat', + ], + 'event.sequence': [44872], + 'agent.type': ['endpoint'], + 'process.executable.text': [ + '/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat', + ], + 'group.Ext.real.name': ['root'], + 'event.module': ['endpoint'], + 'host.os.kernel': ['4.19.0-17-cloud-amd64 #1 SMP Debian 4.19.194-2 (2021-06-21)'], + 'host.os.full.caseless': ['debian 10'], + 'host.id': ['76ea303129f249aa7382338e4263eac1'], + 'process.name.caseless': ['filebeat'], + 'network.type': ['ipv4'], + 'process.executable': [ + '/opt/Elastic/Agent/data/elastic-agent-058c40/install/filebeat-8.0.0-SNAPSHOT-linux-x86_64/filebeat', + ], + 'user.Ext.real.name': ['root'], + 'data_stream.namespace': ['default'], + message: ['Endpoint network event'], + 'destination.ip': ['127.0.0.1'], + 'network.transport': ['tcp'], + 'host.os.Ext.variant': ['Debian'], + 'group.Ext.real.id': ['0'], + 'event.ingested': ['2021-10-14T16:46:04.000Z'], + 'event.action': ['connection_attempted'], + '@timestamp': ['2021-10-14T16:45:58.031Z'], + 'host.os.platform': ['debian'], + 'data_stream.dataset': ['endpoint.events.network'], + 'event.type': ['start'], + 'event.id': ['MKPXftjGeHiQzUNj++++nn6R'], + 'host.os.name.caseless': ['linux'], + 'event.dataset': ['endpoint.events.network'], + 'user.name.text': ['root'], + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/utils/empty_view/use_show_pages_with_empty_view.test.tsx b/x-pack/plugins/security_solution/public/common/utils/empty_view/use_show_pages_with_empty_view.test.tsx new file mode 100644 index 0000000000000..981f7e9e876ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/empty_view/use_show_pages_with_empty_view.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useShowPagesWithEmptyView } from './use_show_pages_with_empty_view'; + +jest.mock('../route/use_route_spy', () => ({ + useRouteSpy: jest + .fn() + .mockImplementationOnce(() => [{ pageName: 'hosts' }]) + .mockImplementationOnce(() => [{ pageName: 'rules' }]) + .mockImplementationOnce(() => [{ pageName: 'network' }]), +})); +jest.mock('../../../common/containers/sourcerer', () => ({ + useSourcererScope: jest + .fn() + .mockImplementationOnce(() => [{ indicesExist: false }]) + .mockImplementationOnce(() => [{ indicesExist: false }]) + .mockImplementationOnce(() => [{ indicesExist: true }]), +})); + +describe('use show pages with empty view', () => { + it('shows empty view when on an elligible page and indices do not exist', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowPagesWithEmptyView()); + await waitForNextUpdate(); + const emptyResult = result.current; + expect(emptyResult).toEqual(true); + }); + }); + it('does not show empty view when on an inelligible page and indices do not exist', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowPagesWithEmptyView()); + await waitForNextUpdate(); + const emptyResult = result.current; + expect(emptyResult).toEqual(false); + }); + }); + it('shows empty view when on an elligible page and indices do exist', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowPagesWithEmptyView()); + await waitForNextUpdate(); + const emptyResult = result.current; + expect(emptyResult).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/empty_view/use_show_pages_with_empty_view.tsx b/x-pack/plugins/security_solution/public/common/utils/empty_view/use_show_pages_with_empty_view.tsx new file mode 100644 index 0000000000000..10cc4be10a61b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/empty_view/use_show_pages_with_empty_view.tsx @@ -0,0 +1,41 @@ +/* + * 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 { useState, useEffect } from 'react'; +import { useRouteSpy } from '../route/use_route_spy'; +import { SecurityPageName } from '../../../app/types'; +import { useSourcererScope } from '../../../common/containers/sourcerer'; + +// Used to detect if we're on a top level page that is empty and set page background color to match the subdued Empty State +const isPageNameWithEmptyView = (currentName: string) => { + const pageNamesWithEmptyView: string[] = [ + SecurityPageName.hosts, + SecurityPageName.network, + SecurityPageName.timelines, + SecurityPageName.overview, + ]; + return pageNamesWithEmptyView.includes(currentName); +}; + +export const useShowPagesWithEmptyView = () => { + const [{ pageName }] = useRouteSpy(); + const { indicesExist } = useSourcererScope(); + + const shouldShowEmptyState = isPageNameWithEmptyView(pageName) && !indicesExist; + + const [showEmptyState, setShowEmptyState] = useState(shouldShowEmptyState); + + useEffect(() => { + if (shouldShowEmptyState) { + setShowEmptyState(true); + } else { + setShowEmptyState(false); + } + }, [shouldShowEmptyState]); + + return showEmptyState; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 29324d186784e..c8d45ca67068a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -94,7 +94,7 @@ export const AlertsCountPanel = memo( {i18n.COUNT_TABLE_TITLE}
} titleSize="s" hideSubtitle > diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 0613c619d89b9..07fa81f27684c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -257,7 +257,11 @@ export const AlertsHistogramPanel = memo( }, [showLinkToAlerts, goToDetectionEngine, formatUrl]); const titleText = useMemo( - () => (onlyField == null ? title : i18n.TOP(onlyField)), + () => ( + + {onlyField == null ? title : i18n.TOP(onlyField)} + + ), [onlyField, title] ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 06d61b3f0b284..a9b6eabecff86 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -34,7 +34,6 @@ import { useExceptionActions } from './use_add_exception_actions'; import { useEventFilterModal } from './use_event_filter_modal'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { useKibana } from '../../../../common/lib/kibana'; -import { useInvestigateInResolverContextItem } from './investigate_in_resolver'; import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/timeline/body/translations'; import { useEventFilterAction } from './use_event_filter_action'; import { useAddToCaseActions } from './use_add_to_case_actions'; @@ -163,30 +162,19 @@ const AlertContextMenuComponent: React.FC !isEvent && ruleId - ? [ - ...investigateInResolverActionItems, - ...addToCaseActionItems, - ...statusActionItems, - ...exceptionActionItems, - ] - : [...investigateInResolverActionItems, ...addToCaseActionItems, ...eventFilterActionItems], + ? [...addToCaseActionItems, ...statusActionItems, ...exceptionActionItems] + : [...addToCaseActionItems, ...eventFilterActionItems], [ statusActionItems, addToCaseActionItems, eventFilterActionItems, exceptionActionItems, - investigateInResolverActionItems, isEvent, ruleId, ] diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx index 528494c9331ac..9d7c2b76b385f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx @@ -6,6 +6,7 @@ */ import React, { FC, memo, useCallback, useEffect } from 'react'; +import { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import { RuleStep, @@ -16,16 +17,25 @@ import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; import { Form, UseField, useForm } from '../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; +import { isThreatMatchRule } from '../../../../../common/detection_engine/utils'; import { NextStep } from '../next_step'; import { schema } from './schema'; interface StepScheduleRuleProps extends RuleStepProps { defaultValues?: ScheduleStepRule | null; + ruleType?: Type; } -const stepScheduleDefaultValue: ScheduleStepRule = { - interval: '5m', - from: '1m', +const DEFAULT_INTERVAL = '5m'; +const DEFAULT_FROM = '1m'; +const THREAT_MATCH_INTERVAL = '1h'; +const THREAT_MATCH_FROM = '5m'; + +const getStepScheduleDefaultValue = (ruleType: Type | undefined): ScheduleStepRule => { + return { + interval: isThreatMatchRule(ruleType) ? THREAT_MATCH_INTERVAL : DEFAULT_INTERVAL, + from: isThreatMatchRule(ruleType) ? THREAT_MATCH_FROM : DEFAULT_FROM, + }; }; const StepScheduleRuleComponent: FC = ({ @@ -37,8 +47,9 @@ const StepScheduleRuleComponent: FC = ({ isUpdateView = false, onSubmit, setForm, + ruleType, }) => { - const initialState = defaultValues ?? stepScheduleDefaultValue; + const initialState = defaultValues ?? getStepScheduleDefaultValue(ruleType); const { form } = useForm({ defaultValue: initialState, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index a2f4385aeeb86..d37acaeb0ffee 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -401,6 +401,7 @@ const CreateRulePageComponent: React.FC = () => { > = ({ detailName, hostDeta ) : ( - - )} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index f647819798ab7..8e6a635624bfe 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -216,8 +216,6 @@ const HostsComponent = () => { ) : ( - - )} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx index e18d3c01791c0..9165aec3bef8d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx @@ -205,7 +205,7 @@ describe('when rendering the PolicyTrustedAppsList', () => { expect(appTestContext.coreStart.application.navigateToApp).toHaveBeenCalledWith( APP_ID, expect.objectContaining({ - path: '/administration/trusted_apps?show=edit&id=89f72d8a-05b5-4350-8cad-0dc3661d6e67', + path: '/administration/trusted_apps?filter=89f72d8a-05b5-4350-8cad-0dc3661d6e67', }) ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx index def0f490b7fee..89ff6bd099be4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx @@ -113,7 +113,7 @@ export const PolicyTrustedAppsList = memo( for (const trustedApp of trustedAppItems) { const isGlobal = trustedApp.effectScope.type === 'global'; - const viewUrlPath = getTrustedAppsListPath({ id: trustedApp.id, show: 'edit' }); + const viewUrlPath = getTrustedAppsListPath({ filter: trustedApp.id }); const assignedPoliciesMenuItems: ArtifactEntryCollapsibleCardProps['policies'] = trustedApp.effectScope.type === 'global' ? undefined diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts index 0602ae18c1408..beefb8587d787 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts @@ -8,6 +8,7 @@ export const SEARCHABLE_FIELDS: Readonly = [ `name`, `description`, + 'item_id', `entries.value`, `entries.entries.value`, ]; diff --git a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx index a3f953fc24fe2..5aee4022a7f30 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/index.tsx @@ -302,8 +302,6 @@ const NetworkDetailsComponent: React.FC = () => { ) : ( - - )} diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index 928570417c524..fe8a9a93f97c7 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -221,7 +221,6 @@ const NetworkComponent = React.memo( ) : ( - )} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index 61e9e66f1bb87..36ecc3371c056 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -42,17 +42,13 @@ describe('OverviewEmpty', () => { }); it('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('noDataConfig')).toEqual({ - actions: { - elasticAgent: { - category: 'security', - description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - title: 'Add a Security integration', - }, + expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ + elasticAgent: { + category: 'security', + description: + 'Use Elastic Agent to collect security events and protect your endpoints from threats.', + title: 'Add a Security integration', }, - docsLink: 'https://www.elastic.co/guide/en/security/mocked-test-branch/index.html', - solution: 'Security', }); }); }); @@ -67,17 +63,13 @@ describe('OverviewEmpty', () => { }); it('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('noDataConfig')).toEqual({ - actions: { - elasticAgent: { - category: 'security', - description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - title: 'Add a Security integration', - }, + expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ + elasticAgent: { + category: 'security', + description: + 'Use Elastic Agent to collect security events and protect your endpoints from threats.', + title: 'Add a Security integration', }, - docsLink: 'https://www.elastic.co/guide/en/security/mocked-test-branch/index.html', - solution: 'Security', }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 9b20c079002e6..023d010ec9a9b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -11,7 +11,7 @@ import { useKibana } from '../../../common/lib/kibana'; import { SOLUTION_NAME } from '../../../../public/common/translations'; import { - KibanaPageTemplate, + NoDataPage, NoDataPageActionsProps, } from '../../../../../../../src/plugins/kibana_react/public'; @@ -32,13 +32,11 @@ const OverviewEmptyComponent: React.FC = () => { }; return ( - ); }; diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 321e6d00b5301..cbeb1464e1b41 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -7,19 +7,24 @@ import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; +import { Filter, Query } from '@kbn/es-query'; import { AlertsHistogramPanel } from '../../../detections/components/alerts_kpis/alerts_histogram_panel'; import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; -import { Filter, Query } from '../../../../../../../src/plugins/data/public'; + import { InputsModelId } from '../../../common/store/inputs/constants'; -import * as i18n from '../../pages/translations'; import { UpdateDateRange } from '../../../common/components/charts/common'; + import { AlertsStackByField } from '../../../detections/components/alerts_kpis/common/types'; +import * as i18n from '../../pages/translations'; + +import { useFiltersForSignalsByCategory } from './use_filters_for_signals_by_category'; + interface Props { combinedQueries?: string; - filters?: Filter[]; + filters: Filter[]; headerChildren?: React.ReactNode; /** Override all defaults, and only display this field */ onlyField?: AlertsStackByField; @@ -43,6 +48,8 @@ const SignalsByCategoryComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const { signalIndexName } = useSignalIndex(); + const filtersForSignalsByCategory = useFiltersForSignalsByCategory(filters); + const updateDateRangeCallback = useCallback( ({ x }) => { if (!x) { @@ -63,7 +70,7 @@ const SignalsByCategoryComponent: React.FC = ({ return ( { + // TODO: Once we are past experimental phase this code should be removed + const ruleRegistryEnabled = useIsExperimentalFeatureEnabled('ruleRegistryEnabled'); + + const resultingFilters = useMemo( + () => [ + ...baseFilters, + ...(ruleRegistryEnabled + ? buildShowBuildingBlockFilterRuleRegistry(SHOW_BUILDING_BLOCK_ALERTS) // TODO: Once we are past experimental phase this code should be removed + : buildShowBuildingBlockFilter(SHOW_BUILDING_BLOCK_ALERTS)), + ], + [baseFilters, ruleRegistryEnabled] + ); + + return resultingFilters; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts index f1e1c42539eff..2521d14481ca8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/__mocks__/index.ts @@ -9,7 +9,7 @@ import { TimelineStatus, TimelineType } from '../../../../../common/types/timeli export const mockTimeline = { data: { - getOneTimeline: { + timeline: { savedObjectId: 'eb2781c0-1df5-11eb-8589-2f13958b79f7', columns: [ { @@ -163,6 +163,7 @@ export const mockTimeline = { version: 'WzQ4NSwxXQ==', __typename: 'TimelineResult', }, + outcome: 'exactMatch', }, loading: false, networkStatus: 7, @@ -171,7 +172,7 @@ export const mockTimeline = { export const mockTemplate = { data: { - getOneTimeline: { + timeline: { savedObjectId: '0c70a200-1de0-11eb-885c-6fc13fca1850', columns: [ { @@ -416,6 +417,7 @@ export const mockTemplate = { version: 'WzQwMywxXQ==', __typename: 'TimelineResult', }, + outcome: 'exactMatch', }, loading: false, networkStatus: 7, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 5d52d2c8a4d48..1b93f1556a95c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -50,7 +50,7 @@ import { mockTimeline as mockSelectedTimeline, mockTemplate as mockSelectedTemplate, } from './__mocks__'; -import { getTimeline } from '../../containers/api'; +import { resolveTimeline } from '../../containers/api'; import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; jest.mock('../../../common/store/inputs/actions'); @@ -951,7 +951,7 @@ describe('helpers', () => { }; beforeAll(async () => { - (getTimeline as jest.Mock).mockRejectedValue(mockError); + (resolveTimeline as jest.Mock).mockRejectedValue(mockError); queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>); }); @@ -986,7 +986,7 @@ describe('helpers', () => { }; beforeAll(async () => { - (getTimeline as jest.Mock).mockResolvedValue(selectedTimeline); + (resolveTimeline as jest.Mock).mockResolvedValue(selectedTimeline); await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>); }); @@ -1002,7 +1002,7 @@ describe('helpers', () => { }); test('get timeline by Id', () => { - expect(getTimeline).toHaveBeenCalled(); + expect(resolveTimeline).toHaveBeenCalled(); }); test('it does not call onError when an error does not occur', () => { @@ -1011,7 +1011,7 @@ describe('helpers', () => { test('Do not override daterange if TimelineStatus is active', () => { const { timeline } = formatTimelineResultToModel( - omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)), + omitTypenameInTimeline(getOr({}, 'data.timeline', selectedTimeline)), args.duplicate, args.timelineType ); @@ -1044,7 +1044,7 @@ describe('helpers', () => { }; beforeAll(async () => { - (getTimeline as jest.Mock).mockResolvedValue(selectedTimeline); + (resolveTimeline as jest.Mock).mockResolvedValue(selectedTimeline); await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>); }); @@ -1060,12 +1060,12 @@ describe('helpers', () => { }); test('get timeline by Id', () => { - expect(getTimeline).toHaveBeenCalled(); + expect(resolveTimeline).toHaveBeenCalled(); }); test('should not override daterange if TimelineStatus is active', () => { const { timeline } = formatTimelineResultToModel( - omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', selectedTimeline)), + omitTypenameInTimeline(getOr({}, 'data.timeline', selectedTimeline)), args.duplicate, args.timelineType ); @@ -1085,6 +1085,10 @@ describe('helpers', () => { to: '2020-07-08T08:20:18.966Z', notes: [], id: TimelineId.active, + resolveTimelineConfig: { + outcome: 'exactMatch', + alias_target_id: undefined, + }, }); }); @@ -1112,12 +1116,12 @@ describe('helpers', () => { }; beforeAll(async () => { - (getTimeline as jest.Mock).mockResolvedValue(template); + (resolveTimeline as jest.Mock).mockResolvedValue(template); await queryTimelineById<{}>(args as unknown as QueryTimelineById<{}>); }); afterAll(() => { - (getTimeline as jest.Mock).mockReset(); + (resolveTimeline as jest.Mock).mockReset(); jest.clearAllMocks(); }); @@ -1129,12 +1133,12 @@ describe('helpers', () => { }); test('get timeline by Id', () => { - expect(getTimeline).toHaveBeenCalled(); + expect(resolveTimeline).toHaveBeenCalled(); }); test('override daterange if TimelineStatus is immutable', () => { const { timeline } = formatTimelineResultToModel( - omitTypenameInTimeline(getOr({}, 'data.getOneTimeline', template)), + omitTypenameInTimeline(getOr({}, 'data.timeline', template)), args.duplicate, args.timelineType ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 1fbddf61f8cd3..f325ab34e88d5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -20,6 +20,7 @@ import { TimelineType, TimelineTabs, TimelineResult, + SingleTimelineResolveResponse, ColumnHeaderResult, FilterTimelineResult, DataProviderResult, @@ -65,7 +66,7 @@ import { DEFAULT_FROM_MOMENT, DEFAULT_TO_MOMENT, } from '../../../common/utils/default_date_settings'; -import { getTimeline } from '../../containers/api'; +import { resolveTimeline } from '../../containers/api'; import { PinnedEvent } from '../../../../common/types/timeline/pinned_event'; import { NoteResult } from '../../../../common/types/timeline/note'; @@ -346,11 +347,12 @@ export const queryTimelineById = ({ updateTimeline, }: QueryTimelineById) => { updateIsLoading({ id: TimelineId.active, isLoading: true }); - Promise.resolve(getTimeline(timelineId)) + Promise.resolve(resolveTimeline(timelineId)) .then((result) => { - const timelineToOpen: TimelineResult = omitTypenameInTimeline( - getOr({}, 'data.getOneTimeline', result) - ); + const data: SingleTimelineResolveResponse['data'] | null = getOr(null, 'data', result); + if (!data) return; + + const timelineToOpen = omitTypenameInTimeline(data.timeline); const { timeline, notes } = formatTimelineResultToModel( timelineToOpen, @@ -370,6 +372,10 @@ export const queryTimelineById = ({ from, id: TimelineId.active, notes, + resolveTimelineConfig: { + outcome: data.outcome, + alias_target_id: data.alias_target_id, + }, timeline: { ...timeline, activeTab: activeTimelineTab, @@ -399,6 +405,7 @@ export const dispatchUpdateTimeline = forceNotes = false, from, notes, + resolveTimelineConfig, timeline, to, ruleNote, @@ -429,7 +436,9 @@ export const dispatchUpdateTimeline = } else { dispatch(dispatchSetTimelineRangeDatePicker({ from, to })); } - dispatch(dispatchAddTimeline({ id, timeline, savedTimeline: duplicate })); + dispatch( + dispatchAddTimeline({ id, timeline, resolveTimelineConfig, savedTimeline: duplicate }) + ); if ( timeline.kqlQuery != null && timeline.kqlQuery.filterQuery != null && diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 79a700856c00f..4c9ce991252dc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -16,6 +16,7 @@ import { TemplateTimelineTypeLiteral, RowRendererId, TimelineStatusLiteralWithNull, + SingleTimelineResolveResponse, } from '../../../../common/types/timeline'; /** The users who added a timeline to favorites */ @@ -194,12 +195,17 @@ export interface OpenTimelineProps { hideActions?: ActionTimelineToShow[]; } +export interface ResolveTimelineConfig { + alias_target_id: SingleTimelineResolveResponse['data']['alias_target_id']; + outcome: SingleTimelineResolveResponse['data']['outcome']; +} export interface UpdateTimeline { duplicate: boolean; id: string; forceNotes?: boolean; from: string; notes: NoteResult[] | null | undefined; + resolveTimelineConfig?: ResolveTimelineConfig; timeline: TimelineModel; to: string; ruleNote?: string; @@ -210,6 +216,7 @@ export type DispatchUpdateTimeline = ({ id, from, notes, + resolveTimelineConfig, timeline, to, ruleNote, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 17d43d80a5a9a..6a7f0602c3675 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -33,6 +33,7 @@ interface Props { isDraggable?: boolean; loading: boolean; messageHeight?: number; + rawEventData: object | undefined; timelineTabType: TimelineTabs | 'flyout'; timelineId: string; hostRisk: HostRisk | null; @@ -93,6 +94,7 @@ export const ExpandableEvent = React.memo( loading, detailsData, hostRisk, + rawEventData, }) => { if (!event.eventId) { return {i18n.EVENT_DETAILS_PLACEHOLDER}; @@ -111,6 +113,7 @@ export const ExpandableEvent = React.memo( id={event.eventId} isAlert={isAlert} isDraggable={isDraggable} + rawEventData={rawEventData} timelineId={timelineId} timelineTabType={timelineTabType} hostRisk={hostRisk} 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 index f8786e0706834..b9d7e0a8c024f 100644 --- 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 @@ -79,7 +79,7 @@ const EventDetailsPanelComponent: React.FC = ({ tabType, timelineId, }) => { - const [loading, detailsData] = useTimelineEventsDetails({ + const [loading, detailsData, rawEventData] = useTimelineEventsDetails({ docValueFields, entityType, indexName: expandedEvent.indexName ?? '', @@ -195,6 +195,7 @@ const EventDetailsPanelComponent: React.FC = ({ isAlert={isAlert} isDraggable={isDraggable} loading={loading} + rawEventData={rawEventData} timelineId={timelineId} timelineTabType="flyout" hostRisk={hostRisk} @@ -228,6 +229,7 @@ const EventDetailsPanelComponent: React.FC = ({ isAlert={isAlert} isDraggable={isDraggable} loading={loading} + rawEventData={rawEventData} timelineId={timelineId} timelineTabType={tabType} hostRisk={hostRisk} 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 5ed9398a621e8..1da09bcf4e25f 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 @@ -185,5 +185,34 @@ describe('Actions', () => { wrapper.find('[data-test-subj="timeline-context-menu-button"]').first().prop('isDisabled') ).toBe(false); }); + test('it shows the analyze event button when the event is from an endpoint', () => { + const ecsData = { + ...mockTimelineData[0].ecs, + event: { kind: ['alert'] }, + agent: { type: ['endpoint'] }, + process: { entity_id: ['1'] }, + }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="view-in-analyzer"]').exists()).toBe(true); + }); + test('it does not render the analyze event button when the event is from an unsupported source', () => { + const ecsData = { + ...mockTimelineData[0].ecs, + event: { kind: ['alert'] }, + agent: { type: ['notendpoint'] }, + }; + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="view-in-analyzer"]').exists()).toBe(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 73650bd320f32..c4dae739cb251 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 @@ -21,9 +21,23 @@ import { EventsTdContent } from '../../styles'; import * as i18n from '../translations'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; -import { TimelineId, ActionProps, OnPinEvent } from '../../../../../../common/types/timeline'; +import { + setActiveTabTimeline, + updateTimelineGraphEventId, +} from '../../../../store/timeline/actions'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../../../common/containers/use_full_screen'; +import { + TimelineId, + ActionProps, + OnPinEvent, + TimelineTabs, +} from '../../../../../../common/types/timeline'; import { timelineActions, timelineSelectors } from '../../../../store/timeline'; import { timelineDefaults } from '../../../../store/timeline/defaults'; +import { isInvestigateInResolverActionEnabled } from '../../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; const ActionsContainer = styled.div` align-items: center; @@ -100,6 +114,24 @@ const ActionsComponent: React.FC = ({ [eventType, ecsData.event?.kind, ecsData.agent?.type] ); + const isDisabled = useMemo(() => !isInvestigateInResolverActionEnabled(ecsData), [ecsData]); + const { setGlobalFullScreen } = useGlobalFullScreen(); + const { setTimelineFullScreen } = useTimelineFullScreen(); + const handleClick = useCallback(() => { + const dataGridIsFullScreen = document.querySelector('.euiDataGrid--fullScreen'); + dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: ecsData._id })); + if (timelineId === TimelineId.active) { + if (dataGridIsFullScreen) { + setTimelineFullScreen(true); + } + dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph })); + } else { + if (dataGridIsFullScreen) { + setGlobalFullScreen(true); + } + } + }, [dispatch, ecsData._id, timelineId, setGlobalFullScreen, setTimelineFullScreen]); + return ( {showCheckboxes && !tGridEnabled && ( @@ -171,6 +203,26 @@ const ActionsComponent: React.FC = ({ refetch={refetch ?? noop} onRuleChange={onRuleChange} /> + {isDisabled === false ? ( +
+ + + + + +
+ ) : null}
); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 6bc2dc089494d..25d5104a98d95 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -521,7 +521,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` "compare": null, "type": [Function], }, - "width": 108, + "width": 140, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx index d38bf2136513e..2cdc8d5f4e284 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/control_columns/index.tsx @@ -9,7 +9,7 @@ import { ControlColumnProps } from '../../../../../../common/types/timeline'; import { Actions } from '../actions'; import { HeaderActions } from '../actions/header_actions'; -const DEFAULT_CONTROL_COLUMN_WIDTH = 108; +const DEFAULT_CONTROL_COLUMN_WIDTH = 140; export const defaultControlColumn: ControlColumnProps = { id: 'default-timeline-control-column', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index 5b993110d38b5..7032319b59333 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -17,8 +17,10 @@ import { import { OnPinEvent, OnUnPinEvent } from '../events'; import * as i18n from './translations'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const omitTypenameAndEmpty = (k: string, v: any): any | undefined => +export const omitTypenameAndEmpty = ( + k: string, + v: string | object | Array +): string | object | Array | undefined => k !== '__typename' && v != null ? v : undefined; export const stringifyEvent = (ecs: Ecs): string => JSON.stringify(ecs, omitTypenameAndEmpty, 2); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index db7a3cc3c9900..c91673e5f931c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -40,6 +40,12 @@ const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; jest.mock('use-resize-observer/polyfilled'); mockUseResizeObserver.mockImplementation(() => ({})); +jest.mock('../../../common/hooks/use_resolve_conflict', () => { + return { + useResolveConflict: jest.fn().mockImplementation(() => null), + }; +}); + jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); 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 a199dd5aa55f8..ca883529b5ce6 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 @@ -28,6 +28,7 @@ import { TabsContent } from './tabs_content'; import { HideShowContainer, TimelineContainer } from './styles'; import { useTimelineFullScreen } from '../../../common/containers/use_full_screen'; import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../common/components/exit_full_screen'; +import { useResolveConflict } from '../../../common/hooks/use_resolve_conflict'; const TimelineTemplateBadge = styled.div` background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; @@ -119,6 +120,7 @@ const StatefulTimelineComponent: React.FC = ({ [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] ); const timelineContext = useMemo(() => ({ timelineId }), [timelineId]); + const resolveConflictComponent = useResolveConflict(); return ( @@ -132,7 +134,7 @@ const StatefulTimelineComponent: React.FC = ({ {timelineType === TimelineType.template && ( {i18n.TIMELINE_TEMPLATE} )} - + {resolveConflictComponent} span { + .euiTab__content { display: flex; flex-direction: row; white-space: pre; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index 7f74912be09b4..44a750cc7283b 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -338,19 +338,6 @@ export const getTimelineTemplate = async (templateTimelineId: string) => { return decodeSingleTimelineResponse(response); }; -export const getResolvedTimelineTemplate = async (templateTimelineId: string) => { - const response = await KibanaServices.get().http.get( - TIMELINE_RESOLVE_URL, - { - query: { - template_timeline_id: templateTimelineId, - }, - } - ); - - return decodeResolvedSingleTimelineResponse(response); -}; - export const getAllTimelines = async (args: GetTimelinesArgs, abortSignal: AbortSignal) => { const response = await KibanaServices.get().http.fetch(TIMELINES_URL, { method: 'GET', diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index e59eaeed4f2a6..f05966bd97870 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -42,7 +42,7 @@ export const useTimelineEventsDetails = ({ indexName, eventId, skip, -}: UseTimelineEventsDetailsProps): [boolean, EventsArgs['detailsData']] => { +}: UseTimelineEventsDetailsProps): [boolean, EventsArgs['detailsData'], object | undefined] => { const { data } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); @@ -55,6 +55,8 @@ export const useTimelineEventsDetails = ({ const [timelineDetailsResponse, setTimelineDetailsResponse] = useState(null); + const [rawEventData, setRawEventData] = useState(undefined); + const timelineDetailsSearch = useCallback( (request: TimelineEventsDetailsRequestOptions | null) => { if (request == null || skip || isEmpty(request.eventId)) { @@ -78,6 +80,7 @@ export const useTimelineEventsDetails = ({ if (isCompleteResponse(response)) { setLoading(false); setTimelineDetailsResponse(response.data || []); + setRawEventData(response.rawResponse.hits.hits[0]); searchSubscription$.current.unsubscribe(); } else if (isErrorResponse(response)) { setLoading(false); @@ -125,5 +128,5 @@ export const useTimelineEventsDetails = ({ }; }, [timelineDetailsRequest, timelineDetailsSearch]); - return [loading, timelineDetailsResponse]; + return [loading, timelineDetailsResponse, rawEventData]; }; diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 728153f47abd7..f79d513380349 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -93,7 +93,6 @@ export const TimelinesPageComponent: React.FC = () => { ) : ( - )} 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 95ad6c5d44ca3..f3a70bd1390ae 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 @@ -25,6 +25,7 @@ import type { SerializedFilterQuery, } from '../../../../common/types/timeline'; import { tGridActions } from '../../../../../timelines/public'; +import { ResolveTimelineConfig } from '../../components/open_timeline/types'; export const { applyDeltaToColumnWidth, clearEventsDeleted, @@ -91,6 +92,7 @@ export const updateTimeline = actionCreator<{ export const addTimeline = actionCreator<{ id: string; timeline: TimelineModel; + resolveTimelineConfig?: ResolveTimelineConfig; savedTimeline?: boolean; }>('ADD_TIMELINE'); 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 4691872bfb927..4c2b8d2992d3d 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 @@ -14,64 +14,65 @@ import { SubsetTimelineModel, TimelineModel } from './model'; // normalizeTimeRange uses getTimeRangeSettings which cannot be used outside Kibana context if the uiSettings is not false const { from: start, to: end } = normalizeTimeRange({ from: '', to: '' }, false); -export const timelineDefaults: SubsetTimelineModel & Pick = - { - activeTab: TimelineTabs.query, - prevActiveTab: TimelineTabs.query, - columns: defaultHeaders, - documentType: '', - defaultColumns: defaultHeaders, - dataProviders: [], - dateRange: { start, end }, - deletedEventIds: [], - description: '', - eqlOptions: { - eventCategoryField: 'event.category', - tiebreakerField: '', - timestampField: '@timestamp', - query: '', - size: 100, +export const timelineDefaults: SubsetTimelineModel & + Pick = { + activeTab: TimelineTabs.query, + prevActiveTab: TimelineTabs.query, + columns: defaultHeaders, + documentType: '', + defaultColumns: defaultHeaders, + dataProviders: [], + dateRange: { start, end }, + deletedEventIds: [], + description: '', + eqlOptions: { + eventCategoryField: 'event.category', + tiebreakerField: '', + timestampField: '@timestamp', + query: '', + size: 100, + }, + eventType: 'all', + eventIdToNoteIds: {}, + excludedRowRendererIds: [], + expandedDetail: {}, + highlightedDropAndProviderId: '', + historyIds: [], + filters: [], + indexNames: [], + isFavorite: false, + isLive: false, + isSelectAllChecked: false, + isLoading: false, + isSaving: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + }, + loadingEventIds: [], + resolveTimelineConfig: undefined, + queryFields: [], + title: '', + timelineType: TimelineType.default, + templateTimelineId: null, + templateTimelineVersion: null, + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: null, + selectAll: false, + selectedEventIds: {}, + show: false, + showCheckboxes: false, + sort: [ + { + columnId: '@timestamp', + columnType: 'number', + sortDirection: 'desc', }, - eventType: 'all', - eventIdToNoteIds: {}, - excludedRowRendererIds: [], - expandedDetail: {}, - highlightedDropAndProviderId: '', - historyIds: [], - filters: [], - indexNames: [], - isFavorite: false, - isLive: false, - isSelectAllChecked: false, - isLoading: false, - isSaving: false, - itemsPerPage: 25, - itemsPerPageOptions: [10, 25, 50, 100], - kqlMode: 'filter', - kqlQuery: { - filterQuery: null, - }, - loadingEventIds: [], - queryFields: [], - title: '', - timelineType: TimelineType.default, - templateTimelineId: null, - templateTimelineVersion: null, - noteIds: [], - pinnedEventIds: {}, - pinnedEventsSaveObject: {}, - savedObjectId: null, - selectAll: false, - selectedEventIds: {}, - show: false, - showCheckboxes: false, - sort: [ - { - columnId: '@timestamp', - columnType: 'number', - sortDirection: 'desc', - }, - ], - status: TimelineStatus.draft, - version: null, - }; + ], + status: TimelineStatus.draft, + version: null, +}; 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 6ee844958aeed..b7af561ae2a04 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 @@ -47,6 +47,7 @@ import { RESIZED_COLUMN_MIN_WITH, } from '../../components/timeline/body/constants'; import { activeTimeline } from '../../containers/active_timeline_context'; +import { ResolveTimelineConfig } from '../../components/open_timeline/types'; export const isNotNull = (value: T | null): value is T => value !== null; @@ -124,6 +125,7 @@ export const addTimelineNoteToEvent = ({ interface AddTimelineParams { id: string; + resolveTimelineConfig?: ResolveTimelineConfig; timeline: TimelineModel; timelineById: TimelineById; } @@ -145,6 +147,7 @@ export const shouldResetActiveTimelineContext = ( */ export const addTimelineToStore = ({ id, + resolveTimelineConfig, timeline, timelineById, }: AddTimelineParams): TimelineById => { @@ -159,6 +162,7 @@ export const addTimelineToStore = ({ filterManager: timelineById[id].filterManager, isLoading: timelineById[id].isLoading, initialized: timelineById[id].initialized, + resolveTimelineConfig, dateRange: timeline.status === TimelineStatus.immutable && timeline.timelineType === TimelineType.template 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 b53da997c08cb..29b49197ef797 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 @@ -15,6 +15,7 @@ import type { } from '../../../../common/types/timeline'; import { PinnedEvent } from '../../../../common/types/timeline/pinned_event'; import type { TGridModelForTimeline } from '../../../../../timelines/public'; +import { ResolveTimelineConfig } from '../../components/open_timeline/types'; export const DEFAULT_PAGE_COUNT = 2; // Eui Pager will not render unless this is a minimum of 2 pages export type KqlMode = 'filter' | 'search'; @@ -59,6 +60,7 @@ export type TimelineModel = TGridModelForTimeline & { /** Events pinned to this timeline */ pinnedEventIds: Record; pinnedEventsSaveObject: Record; + resolveTimelineConfig?: ResolveTimelineConfig; showSaveModal?: boolean; savedQueryId?: string | null; /** When true, show the timeline flyover */ 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 97fa72667a3c6..e997bbd848d50 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 @@ -94,9 +94,14 @@ export const initialTimelineState: TimelineState = { /** The reducer for all timeline actions */ export const timelineReducer = reducerWithInitialState(initialTimelineState) - .case(addTimeline, (state, { id, timeline }) => ({ + .case(addTimeline, (state, { id, timeline, resolveTimelineConfig }) => ({ ...state, - timelineById: addTimelineToStore({ id, timeline, timelineById: state.timelineById }), + timelineById: addTimelineToStore({ + id, + timeline, + resolveTimelineConfig, + timelineById: state.timelineById, + }), })) .case(createTimeline, (state, { id, timelineType = TimelineType.default, ...timelineProps }) => { return { diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index bee0e9b3a3d1d..61813d1a122b4 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -9,7 +9,6 @@ import { CoreStart } from '../../../../src/core/public'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; -import { SpacesPluginStart } from '../../../plugins/spaces/public'; import { LensPublicStart } from '../../../plugins/lens/public'; import { NewsfeedPublicPluginStart } from '../../../../src/plugins/newsfeed/public'; import { Start as InspectorStart } from '../../../../src/plugins/inspector/public'; @@ -18,6 +17,7 @@ import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/p import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { FleetStart } from '../../fleet/public'; import { PluginStart as ListsPluginStart } from '../../lists/public'; +import { SpacesPluginStart } from '../../spaces/public'; import { TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, diff --git a/x-pack/plugins/security_solution/server/config.mock.ts b/x-pack/plugins/security_solution/server/config.mock.ts new file mode 100644 index 0000000000000..c1d1e02ca35f4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/config.mock.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DEFAULT_SIGNALS_INDEX, SIGNALS_INDEX_KEY } from '../common/constants'; +import { + ExperimentalFeatures, + parseExperimentalConfigValue, +} from '../common/experimental_features'; +import { ConfigType } from './config'; +import { UnderlyingLogClient } from './lib/detection_engine/rule_execution_log/types'; + +export const createMockConfig = (): ConfigType => { + const enableExperimental: string[] = []; + + return { + [SIGNALS_INDEX_KEY]: DEFAULT_SIGNALS_INDEX, + maxRuleImportExportSize: 10000, + maxRuleImportPayloadBytes: 10485760, + maxTimelineImportExportSize: 10000, + maxTimelineImportPayloadBytes: 10485760, + enableExperimental, + endpointResultListDefaultFirstPageIndex: 0, + endpointResultListDefaultPageSize: 10, + packagerTaskInterval: '60s', + alertMergeStrategy: 'missingFields', + alertIgnoreFields: [], + prebuiltRulesFromFileSystem: true, + prebuiltRulesFromSavedObjects: false, + ruleExecutionLog: { + underlyingClient: UnderlyingLogClient.savedObjects, + }, + + kibanaIndex: '.kibana', + experimentalFeatures: parseExperimentalConfigValue(enableExperimental), + }; +}; + +const withExperimentalFeature = ( + config: ConfigType, + feature: keyof ExperimentalFeatures +): ConfigType => { + const enableExperimental = config.enableExperimental.concat(feature); + return { + ...config, + enableExperimental, + experimentalFeatures: parseExperimentalConfigValue(enableExperimental), + }; +}; + +const withRuleRegistryEnabled = (config: ConfigType, isEnabled: boolean): ConfigType => { + return isEnabled ? withExperimentalFeature(config, 'ruleRegistryEnabled') : config; +}; + +export const configMock = { + createDefault: createMockConfig, + withExperimentalFeature, + withRuleRegistryEnabled, +}; diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index e0b8ad883f4a2..072e23b7a773c 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -9,8 +9,10 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { PluginInitializerContext } from '../../../../src/core/server'; import { SIGNALS_INDEX_KEY, DEFAULT_SIGNALS_INDEX } from '../common/constants'; import { + ExperimentalFeatures, getExperimentalAllowedValues, isValidExperimentalValue, + parseExperimentalConfigValue, } from '../common/experimental_features'; import { UnderlyingLogClient } from './lib/detection_engine/rule_execution_log/types'; @@ -112,7 +114,7 @@ export const configSchema = schema.object({ schema.literal(UnderlyingLogClient.eventLog), schema.literal(UnderlyingLogClient.savedObjects), ], - { defaultValue: UnderlyingLogClient.savedObjects } + { defaultValue: UnderlyingLogClient.eventLog } ), }), @@ -134,7 +136,23 @@ export const configSchema = schema.object({ prebuiltRulesFromSavedObjects: schema.boolean({ defaultValue: true }), }); -export const createConfig = (context: PluginInitializerContext) => - context.config.get>(); +export type ConfigSchema = TypeOf; -export type ConfigType = TypeOf; +export type ConfigType = ConfigSchema & { + kibanaIndex: string; + experimentalFeatures: ExperimentalFeatures; +}; + +export const createConfig = (context: PluginInitializerContext): ConfigType => { + const globalConfig = context.config.legacy.get(); + const pluginConfig = context.config.get>(); + + const kibanaIndex = globalConfig.kibana.index; + const experimentalFeatures = parseExperimentalConfigValue(pluginConfig.enableExperimental); + + return { + ...pluginConfig, + kibanaIndex, + experimentalFeatures, + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 5a47c8a616c00..caea18da75ae4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -27,13 +27,18 @@ import { import { ManifestManager } from './services/artifacts'; import { AppClientFactory } from '../client'; import { ConfigType } from '../config'; +import { IRequestContextFactory } from '../request_context_factory'; import { LicenseService } from '../../common/license'; -import { - ExperimentalFeatures, - parseExperimentalConfigValue, -} from '../../common/experimental_features'; +import { ExperimentalFeatures } from '../../common/experimental_features'; import { EndpointMetadataService } from './services/metadata'; -import { EndpointAppContentServicesNotStartedError } from './errors'; +import { + EndpointAppContentServicesNotSetUpError, + EndpointAppContentServicesNotStartedError, +} from './errors'; + +export interface EndpointAppContextServiceSetupContract { + securitySolutionRequestContextFactory: IRequestContextFactory; +} export type EndpointAppContextServiceStartContract = Partial< Pick< @@ -59,40 +64,29 @@ export type EndpointAppContextServiceStartContract = Partial< * of the plugin lifecycle. And stop during the stop phase, if needed. */ export class EndpointAppContextService { - private agentService: AgentService | undefined; - private manifestManager: ManifestManager | undefined; - private packagePolicyService: PackagePolicyServiceInterface | undefined; - private agentPolicyService: AgentPolicyServiceInterface | undefined; - private config: ConfigType | undefined; - private license: LicenseService | undefined; + private setupDependencies: EndpointAppContextServiceSetupContract | null = null; + private startDependencies: EndpointAppContextServiceStartContract | null = null; public security: SecurityPluginStart | undefined; - private cases: CasesPluginStartContract | undefined; - private endpointMetadataService: EndpointMetadataService | undefined; - private experimentalFeatures: ExperimentalFeatures | undefined; + + public setup(dependencies: EndpointAppContextServiceSetupContract) { + this.setupDependencies = dependencies; + } public start(dependencies: EndpointAppContextServiceStartContract) { - this.agentService = dependencies.agentService; - this.packagePolicyService = dependencies.packagePolicyService; - this.agentPolicyService = dependencies.agentPolicyService; - this.manifestManager = dependencies.manifestManager; - this.config = dependencies.config; - this.license = dependencies.licenseService; + if (!this.setupDependencies) { + throw new EndpointAppContentServicesNotSetUpError(); + } + + this.startDependencies = dependencies; this.security = dependencies.security; - this.cases = dependencies.cases; - this.endpointMetadataService = dependencies.endpointMetadataService; - this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental); - if (this.manifestManager && dependencies.registerIngestCallback) { + if (dependencies.registerIngestCallback && dependencies.manifestManager) { dependencies.registerIngestCallback( 'packagePolicyCreate', getPackagePolicyCreateCallback( dependencies.logger, - this.manifestManager, - dependencies.appClientFactory, - dependencies.config.maxTimelineImportExportSize, - dependencies.config.prebuiltRulesFromFileSystem, - dependencies.config.prebuiltRulesFromSavedObjects, - dependencies.security, + dependencies.manifestManager, + this.setupDependencies.securitySolutionRequestContextFactory, dependencies.alerting, dependencies.licenseService, dependencies.exceptionListsClient @@ -106,7 +100,10 @@ export class EndpointAppContextService { dependencies.registerIngestCallback( 'postPackagePolicyDelete', - getPackagePolicyDeleteCallback(dependencies.exceptionListsClient, this.experimentalFeatures) + getPackagePolicyDeleteCallback( + dependencies.exceptionListsClient, + dependencies.config.experimentalFeatures + ) ); } } @@ -114,43 +111,43 @@ export class EndpointAppContextService { public stop() {} public getExperimentalFeatures(): Readonly | undefined { - return this.experimentalFeatures; + return this.startDependencies?.config.experimentalFeatures; } public getEndpointMetadataService(): EndpointMetadataService { - if (!this.endpointMetadataService) { + if (this.startDependencies == null) { throw new EndpointAppContentServicesNotStartedError(); } - return this.endpointMetadataService; + return this.startDependencies.endpointMetadataService; } public getAgentService(): AgentService | undefined { - return this.agentService; + return this.startDependencies?.agentService; } public getPackagePolicyService(): PackagePolicyServiceInterface | undefined { - return this.packagePolicyService; + return this.startDependencies?.packagePolicyService; } public getAgentPolicyService(): AgentPolicyServiceInterface | undefined { - return this.agentPolicyService; + return this.startDependencies?.agentPolicyService; } public getManifestManager(): ManifestManager | undefined { - return this.manifestManager; + return this.startDependencies?.manifestManager; } public getLicenseService(): LicenseService { - if (!this.license) { + if (this.startDependencies == null) { throw new EndpointAppContentServicesNotStartedError(); } - return this.license; + return this.startDependencies.licenseService; } public async getCasesClient(req: KibanaRequest): Promise { - if (!this.cases) { + if (this.startDependencies?.cases == null) { throw new EndpointAppContentServicesNotStartedError(); } - return this.cases.getCasesClientWithRequest(req); + return this.startDependencies.cases.getCasesClientWithRequest(req); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/errors.ts b/x-pack/plugins/security_solution/server/endpoint/errors.ts index fae15984d9c44..7260d6055b310 100644 --- a/x-pack/plugins/security_solution/server/endpoint/errors.ts +++ b/x-pack/plugins/security_solution/server/endpoint/errors.ts @@ -17,6 +17,12 @@ export class EndpointError extends Error { export class NotFoundError extends EndpointError {} +export class EndpointAppContentServicesNotSetUpError extends EndpointError { + constructor() { + super('EndpointAppContextService has not been set up (EndpointAppContextService.setup())'); + } +} + export class EndpointAppContentServicesNotStartedError extends EndpointError { constructor() { super('EndpointAppContextService has not been started (EndpointAppContextService.start())'); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts index af5e386464305..60f91330d4558 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts @@ -25,6 +25,9 @@ export const ArtifactConstants = { SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS: ['macos', 'windows', 'linux'], GLOBAL_EVENT_FILTERS_NAME: 'endpoint-eventfilterlist', + + SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS: ['macos', 'windows', 'linux'], + GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME: 'endpoint-hostisolationexceptionlist', }; export const ManifestConstants = { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index e27a09efd9710..e26a2c7f4b4bc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -15,6 +15,7 @@ import { validate } from '@kbn/securitysolution-io-ts-utils'; import { ENDPOINT_EVENT_FILTERS_LIST_ID, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, } from '@kbn/securitysolution-list-constants'; @@ -65,6 +66,7 @@ export async function getFilteredEndpointExceptionList( | typeof ENDPOINT_LIST_ID | typeof ENDPOINT_TRUSTED_APPS_LIST_ID | typeof ENDPOINT_EVENT_FILTERS_LIST_ID + | typeof ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID ): Promise { const exceptions: WrappedTranslatedExceptionList = { entries: [] }; let page = 1; @@ -148,6 +150,24 @@ export async function getEndpointEventFiltersList( ); } +export async function getHostIsolationExceptionsList( + eClient: ExceptionListClient, + schemaVersion: string, + os: string, + policyId?: string +): Promise { + const osFilter = `exception-list-agnostic.attributes.os_types:\"${os}\"`; + const policyFilter = `(exception-list-agnostic.attributes.tags:\"policy:all\"${ + policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' + })`; + + return getFilteredEndpointExceptionList( + eClient, + schemaVersion, + `${osFilter} and ${policyFilter}`, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID + ); +} /** * Translates Exception list items to Exceptions the endpoint can understand * @param exceptions diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 39833b6e995d1..190770f3d860d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -22,6 +22,7 @@ import { AppClientFactory } from '../client'; import { createMockConfig } from '../lib/detection_engine/routes/__mocks__'; import { EndpointAppContextService, + EndpointAppContextServiceSetupContract, EndpointAppContextServiceStartContract, } from './endpoint_app_context_services'; import { ManifestManager } from './services/artifacts/manifest_manager/manifest_manager'; @@ -37,6 +38,7 @@ import { parseExperimentalConfigValue } from '../../common/experimental_features // a restricted path. // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { createCasesClientMock } from '../../../cases/server/client/mocks'; +import { requestContextFactoryMock } from '../request_context_factory.mock'; import { EndpointMetadataService } from './services/metadata'; /** @@ -69,13 +71,25 @@ export const createMockEndpointAppContextService = ( } as unknown as jest.Mocked; }; +/** + * Creates a mocked input contract for the `EndpointAppContextService#setup()` method + */ +export const createMockEndpointAppContextServiceSetupContract = + (): jest.Mocked => { + return { + securitySolutionRequestContextFactory: requestContextFactoryMock.create(), + }; + }; + /** * Creates a mocked input contract for the `EndpointAppContextService#start()` method */ export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked => { - const factory = new AppClientFactory(); const config = createMockConfig(); + const factory = new AppClientFactory(); + factory.setup({ getSpaceId: () => 'mockSpace', config }); + const casesClientMock = createCasesClientMock(); const savedObjectsStart = savedObjectsServiceMock.createStartContract(); const agentService = createMockAgentService(); @@ -86,8 +100,6 @@ export const createMockEndpointAppContextServiceStartContract = agentPolicyService ); - factory.setup({ getSpaceId: () => 'mockSpace', config }); - return { agentService, agentPolicyService, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts index 5ce7962000788..c6df8c9183917 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts @@ -25,6 +25,7 @@ import { parseExperimentalConfigValue } from '../../../../common/experimental_fe import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { + createMockEndpointAppContextServiceSetupContract, createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; @@ -130,6 +131,7 @@ describe('Action Log API', () => { const esClientMock = elasticsearchServiceMock.createScopedClusterClient(); const routerMock = httpServiceMock.createRouter(); endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); registerActionAuditLogRoutes(routerMock, { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index ee3bc5e1f21e3..a483a33ea4c8d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -18,6 +18,7 @@ import { parseExperimentalConfigValue } from '../../../../common/experimental_fe import { SecuritySolutionRequestHandlerContext } from '../../../types'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { + createMockEndpointAppContextServiceSetupContract, createMockEndpointAppContextServiceStartContract, createMockPackageService, createRouteHandlerContext, @@ -157,9 +158,12 @@ describe('Host Isolation', () => { keep_policies_up_to_date: false, }) ); + licenseEmitter = new Subject(); licenseService = new LicenseService(); licenseService.start(licenseEmitter); + + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); endpointAppContextService.start({ ...startContract, licenseService, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts index facd53643bc4f..2f8ba30936f25 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts @@ -21,6 +21,7 @@ import { parseExperimentalConfigValue } from '../../../../common/experimental_fe import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { + createMockEndpointAppContextServiceSetupContract, createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; @@ -67,6 +68,7 @@ describe('Endpoint Action Status', () => { const esClientMock = elasticsearchServiceMock.createScopedClusterClient(); const routerMock = httpServiceMock.createRouter(); endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); registerActionStatusRoutes(routerMock, { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 3e5050c05814a..7c2e5de928484 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -22,6 +22,7 @@ import { HostInfo, HostResultList, HostStatus } from '../../../../common/endpoin import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; import { registerEndpointRoutes } from './index'; import { + createMockEndpointAppContextServiceSetupContract, createMockEndpointAppContextServiceStartContract, createMockPackageService, createRouteHandlerContext, @@ -134,6 +135,7 @@ describe('test endpoint route', () => { keep_policies_up_to_date: false, }) ); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); mockAgentService = startContract.agentService!; @@ -394,6 +396,7 @@ describe('test endpoint route', () => { keep_policies_up_to_date: false, }) ); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); mockAgentService = startContract.agentService!; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index 90cda7ceb05d4..f25171c6734c8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -7,6 +7,7 @@ import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { + createMockEndpointAppContextServiceSetupContract, createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; @@ -45,6 +46,7 @@ describe('test policy response handler', () => { mockSavedObjectClient = savedObjectsClientMock.create(); mockResponse = httpServerMock.createResponseFactory(); endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); }); @@ -161,6 +163,7 @@ describe('test policy response handler', () => { page: 1, perPage: 1, }; + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); endpointAppContextService.start({ ...createMockEndpointAppContextServiceStartContract(), ...{ agentService: mockAgentService }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index 547c1f6a2e5ff..614ad4fb548ea 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -110,7 +110,7 @@ const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } const packagePolicyClient = createPackagePolicyServiceMock() as jest.Mocked; -describe('handlers', () => { +describe('TrustedApps API Handlers', () => { beforeEach(() => { packagePolicyClient.getByIDs.mockReset(); }); @@ -195,6 +195,7 @@ describe('handlers', () => { const mockResponse = httpServerMock.createResponseFactory(); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await deleteTrustedAppHandler( createHandlerContextMock(), @@ -582,7 +583,7 @@ describe('handlers', () => { }); it('should return 404 if trusted app does not exist', async () => { - exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await updateHandler( createHandlerContextMock(), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts index 2c085c14db009..08c1a3a809d4a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts @@ -122,7 +122,7 @@ export const exceptionListItemToTrustedApp = ( const grouped = entriesToConditionEntriesMap(exceptionListItem.entries); return { - id: exceptionListItem.id, + id: exceptionListItem.item_id, version: exceptionListItem._version || '', name: exceptionListItem.name, description: exceptionListItem.description, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index dce84df735929..c57416ff1c974 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -85,9 +85,10 @@ const TRUSTED_APP: TrustedApp = { ], }; -describe('service', () => { +describe('TrustedApps service', () => { beforeEach(() => { exceptionsListClient.deleteExceptionListItem.mockReset(); + exceptionsListClient.getExceptionListItem.mockReset(); exceptionsListClient.createExceptionListItem.mockReset(); exceptionsListClient.findExceptionListItem.mockReset(); exceptionsListClient.createTrustedAppsList.mockReset(); @@ -96,6 +97,7 @@ describe('service', () => { describe('deleteTrustedApp', () => { it('should delete existing trusted app', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); expect(await deleteTrustedApp(exceptionsListClient, { id: '123' })).toBeUndefined(); @@ -107,6 +109,7 @@ describe('service', () => { }); it('should throw for non existing trusted app', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(null); await expect(deleteTrustedApp(exceptionsListClient, { id: '123' })).rejects.toBeInstanceOf( @@ -393,7 +396,7 @@ describe('service', () => { }); it('should throw a Not Found error if trusted app is not found prior to making update', async () => { - exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await expect( updateTrustedApp( exceptionsListClient, @@ -489,5 +492,22 @@ describe('service', () => { TrustedAppNotFoundError ); }); + + it('should try to find trusted app by `itemId` and then by `id`', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); + await getTrustedApp(exceptionsListClient, '123').catch(() => Promise.resolve()); + + expect(exceptionsListClient.getExceptionListItem).toHaveBeenCalledTimes(2); + expect(exceptionsListClient.getExceptionListItem).toHaveBeenNthCalledWith(1, { + itemId: '123', + id: undefined, + namespaceType: 'agnostic', + }); + expect(exceptionsListClient.getExceptionListItem).toHaveBeenNthCalledWith(2, { + itemId: undefined, + id: '123', + namespaceType: 'agnostic', + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts index 856a615c1ffa2..7a4b2372ece8f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts @@ -15,13 +15,13 @@ import { DeleteTrustedAppsRequestParams, GetOneTrustedAppResponse, GetTrustedAppsListRequest, - GetTrustedAppsSummaryResponse, GetTrustedAppsListResponse, + GetTrustedAppsSummaryRequest, + GetTrustedAppsSummaryResponse, PostTrustedAppCreateRequest, PostTrustedAppCreateResponse, PutTrustedAppUpdateRequest, PutTrustedAppUpdateResponse, - GetTrustedAppsSummaryRequest, TrustedApp, } from '../../../../common/endpoint/types'; @@ -33,8 +33,8 @@ import { } from './mapping'; import { TrustedAppNotFoundError, - TrustedAppVersionConflictError, TrustedAppPolicyNotExistsError, + TrustedAppVersionConflictError, } from './errors'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { PackagePolicy } from '../../../../../fleet/common'; @@ -87,30 +87,61 @@ const isUserTryingToModifyEffectScopeWithoutPermissions = ( } }; -export const deleteTrustedApp = async ( +/** + * Attempts to first fine the ExceptionItem using `item_id` and if not found, then a second attempt wil be done + * against the Saved Object `id`. + * @param exceptionsListClient + * @param id + */ +export const findTrustedAppExceptionItemByIdOrItemId = async ( exceptionsListClient: ExceptionListClient, - { id }: DeleteTrustedAppsRequestParams -) => { - const exceptionListItem = await exceptionsListClient.deleteExceptionListItem({ - id, + id: string +): Promise => { + const trustedAppExceptionItem = await exceptionsListClient.getExceptionListItem({ + itemId: id, + id: undefined, + namespaceType: 'agnostic', + }); + + if (trustedAppExceptionItem) { + return trustedAppExceptionItem; + } + + return exceptionsListClient.getExceptionListItem({ itemId: undefined, + id, namespaceType: 'agnostic', }); +}; - if (!exceptionListItem) { +export const deleteTrustedApp = async ( + exceptionsListClient: ExceptionListClient, + { id }: DeleteTrustedAppsRequestParams +): Promise => { + const trustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); + + if (!trustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); } + + await exceptionsListClient.deleteExceptionListItem({ + id: trustedAppExceptionItem.id, + itemId: undefined, + namespaceType: 'agnostic', + }); }; export const getTrustedApp = async ( exceptionsListClient: ExceptionListClient, id: string ): Promise => { - const trustedAppExceptionItem = await exceptionsListClient.getExceptionListItem({ - itemId: '', - id, - namespaceType: 'agnostic', - }); + const trustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); if (!trustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); @@ -189,19 +220,18 @@ export const updateTrustedApp = async ( updatedTrustedApp: PutTrustedAppUpdateRequest, isAtLeastPlatinum: boolean ): Promise => { - const currentTrustedApp = await exceptionsListClient.getExceptionListItem({ - itemId: '', - id, - namespaceType: 'agnostic', - }); + const currentTrustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); - if (!currentTrustedApp) { + if (!currentTrustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); } if ( isUserTryingToModifyEffectScopeWithoutPermissions( - exceptionListItemToTrustedApp(currentTrustedApp), + exceptionListItemToTrustedApp(currentTrustedAppExceptionItem), updatedTrustedApp, isAtLeastPlatinum ) @@ -226,7 +256,10 @@ export const updateTrustedApp = async ( try { updatedTrustedAppExceptionItem = await exceptionsListClient.updateExceptionListItem( - updatedTrustedAppToUpdateExceptionListItemOptions(currentTrustedApp, updatedTrustedApp) + updatedTrustedAppToUpdateExceptionListItemOptions( + currentTrustedAppExceptionItem, + updatedTrustedApp + ) ); } catch (e) { if (e?.output?.statusCode === 409) { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts index d59ecb674196c..b25b599517300 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts @@ -9,6 +9,7 @@ import { ElasticsearchClient, Logger } from 'kibana/server'; import { SearchHit, SearchResponse } from '@elastic/elasticsearch/api/types'; import { ApiResponse } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; +import { ENDPOINT_ACTION_RESPONSES_INDEX } from '../../../common/endpoint/constants'; import { SecuritySolutionRequestHandlerContext } from '../../types'; import { ActivityLog, @@ -146,6 +147,41 @@ const getActivityLog = async ({ return sortedData; }; +const hasAckInResponse = (response: EndpointActionResponse): boolean => { + return typeof response.action_data.ack !== 'undefined'; +}; + +// return TRUE if for given action_id/agent_id +// there is no doc in .logs-endpoint.action.response-default +const hasNoEndpointResponse = ({ + action, + agentId, + indexedActionIds, +}: { + action: EndpointAction; + agentId: string; + indexedActionIds: string[]; +}): boolean => { + return action.agents.includes(agentId) && !indexedActionIds.includes(action.action_id); +}; + +// return TRUE if for given action_id/agent_id +// there is no doc in .fleet-actions-results +const hasNoFleetResponse = ({ + action, + agentId, + agentResponses, +}: { + action: EndpointAction; + agentId: string; + agentResponses: EndpointActionResponse[]; +}): boolean => { + return ( + action.agents.includes(agentId) && + !agentResponses.map((e) => e.action_id).includes(action.action_id) + ); +}; + export const getPendingActionCounts = async ( esClient: ElasticsearchClient, metadataService: EndpointMetadataService, @@ -179,21 +215,45 @@ export const getPendingActionCounts = async ( .catch(catchAndWrapError); // retrieve any responses to those action IDs from these agents - const responses = await fetchActionResponseIds( + const responses = await fetchActionResponses( esClient, metadataService, recentActions.map((a) => a.action_id), agentIDs ); - const pending: EndpointPendingActions[] = []; + // + + const pending: EndpointPendingActions[] = []; for (const agentId of agentIDs) { - const responseIDsFromAgent = responses[agentId]; + const agentResponses = responses[agentId]; + + // get response actionIds for responses with ACKs + const ackResponseActionIdList: string[] = agentResponses + .filter(hasAckInResponse) + .map((response) => response.action_id); + + // actions Ids that are indexed in new response index + const indexedActionIds = await hasEndpointResponseDoc({ + agentId, + actionIds: ackResponseActionIdList, + esClient, + }); + + const pendingActions: EndpointAction[] = recentActions.filter((action) => { + return ackResponseActionIdList.includes(action.action_id) // if has ack + ? hasNoEndpointResponse({ action, agentId, indexedActionIds }) // then find responses in new index + : hasNoFleetResponse({ + // else use the legacy way + action, + agentId, + agentResponses, + }); + }); pending.push({ agent_id: agentId, - pending_actions: recentActions - .filter((a) => a.agents.includes(agentId) && !responseIDsFromAgent.includes(a.action_id)) + pending_actions: pendingActions .map((a) => a.data.command) .reduce((acc, cur) => { if (cur in acc) { @@ -209,6 +269,43 @@ export const getPendingActionCounts = async ( return pending; }; +/** + * Returns a boolean for search result + * + * @param esClient + * @param actionIds + * @param agentIds + */ +const hasEndpointResponseDoc = async ({ + actionIds, + agentId, + esClient, +}: { + actionIds: string[]; + agentId: string; + esClient: ElasticsearchClient; +}): Promise => { + const response = await esClient + .search( + { + index: ENDPOINT_ACTION_RESPONSES_INDEX, + body: { + query: { + bool: { + filter: [{ terms: { action_id: actionIds } }, { term: { agent_id: agentId } }], + }, + }, + }, + }, + { ignore: [404] } + ) + .then( + (result) => result.body?.hits?.hits?.map((a) => a._source?.EndpointActions.action_id) || [] + ) + .catch(catchAndWrapError); + return response.filter((action): action is string => action !== undefined); +}; + /** * Returns back a map of elastic Agent IDs to array of Action IDs that have received a response. * @@ -217,16 +314,19 @@ export const getPendingActionCounts = async ( * @param actionIds * @param agentIds */ -const fetchActionResponseIds = async ( +const fetchActionResponses = async ( esClient: ElasticsearchClient, metadataService: EndpointMetadataService, actionIds: string[], agentIds: string[] -): Promise> => { - const actionResponsesByAgentId: Record = agentIds.reduce((acc, agentId) => { - acc[agentId] = []; - return acc; - }, {} as Record); +): Promise> => { + const actionResponsesByAgentId: Record = agentIds.reduce( + (acc, agentId) => { + acc[agentId] = []; + return acc; + }, + {} as Record + ); const actionResponses = await esClient .search( @@ -255,7 +355,7 @@ const fetchActionResponseIds = async ( return actionResponsesByAgentId; } - // Get the latest docs from the metadata datastream for the Elastic Agent IDs in the action responses + // Get the latest docs from the metadata data-stream for the Elastic Agent IDs in the action responses // This will be used determine if we should withhold the action id from the returned list in cases where // the Endpoint might not yet have sent an updated metadata document (which would be representative of // the state of the endpoint post-action) @@ -288,7 +388,7 @@ const fetchActionResponseIds = async ( enoughTimeHasLapsed || lastEndpointMetadataEventTimestamp > actionCompletedAtTimestamp ) { - actionResponsesByAgentId[actionResponse.agent_id].push(actionResponse.action_id); + actionResponsesByAgentId[actionResponse.agent_id].push(actionResponse); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index d75e347b86bd5..0ef2abd5f50aa 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -7,6 +7,7 @@ import { savedObjectsClientMock } from 'src/core/server/mocks'; import { + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, } from '@kbn/securitysolution-list-constants'; @@ -66,6 +67,12 @@ describe('ManifestManager', () => { const ARTIFACT_NAME_EVENT_FILTERS_MACOS = 'endpoint-eventfilterlist-macos-v1'; const ARTIFACT_NAME_EVENT_FILTERS_WINDOWS = 'endpoint-eventfilterlist-windows-v1'; const ARTIFACT_NAME_EVENT_FILTERS_LINUX = 'endpoint-eventfilterlist-linux-v1'; + const ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_MACOS = + 'endpoint-hostisolationexceptionlist-macos-v1'; + const ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_WINDOWS = + 'endpoint-hostisolationexceptionlist-windows-v1'; + const ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_LINUX = + 'endpoint-hostisolationexceptionlist-linux-v1'; let ARTIFACTS: InternalArtifactCompleteSchema[] = []; let ARTIFACTS_BY_ID: { [K: string]: InternalArtifactCompleteSchema } = {}; @@ -157,31 +164,29 @@ describe('ManifestManager', () => { const manifestManagerContext = buildManifestManagerContextMock({ savedObjectsClient }); const manifestManager = new ManifestManager(manifestManagerContext); - savedObjectsClient.get = jest - .fn() - .mockImplementation(async (objectType: string, id: string) => { - if (objectType === ManifestConstants.SAVED_OBJECT_TYPE) { - return { - attributes: { - created: '20-01-2020 10:00:00.000Z', - schemaVersion: 'v2', - semanticVersion: '1.0.0', - artifacts: [ - { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_LINUX, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_2 }, - ], - }, - version: '2.0.0', - }; - } else { - return null; - } - }); + savedObjectsClient.get = jest.fn().mockImplementation(async (objectType: string) => { + if (objectType === ManifestConstants.SAVED_OBJECT_TYPE) { + return { + attributes: { + created: '20-01-2020 10:00:00.000Z', + schemaVersion: 'v2', + semanticVersion: '1.0.0', + artifacts: [ + { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_LINUX, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_2 }, + ], + }, + version: '2.0.0', + }; + } else { + return null; + } + }); ( manifestManagerContext.artifactClient as jest.Mocked @@ -218,31 +223,29 @@ describe('ManifestManager', () => { const manifestManagerContext = buildManifestManagerContextMock({ savedObjectsClient }); const manifestManager = new ManifestManager(manifestManagerContext); - savedObjectsClient.get = jest - .fn() - .mockImplementation(async (objectType: string, id: string) => { - if (objectType === ManifestConstants.SAVED_OBJECT_TYPE) { - return { - attributes: { - created: '20-01-2020 10:00:00.000Z', - schemaVersion: 'v2', - semanticVersion: '1.0.0', - artifacts: [ - { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_LINUX, policyId: undefined }, - { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_1 }, - { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_2 }, - ], - }, - version: '2.0.0', - }; - } else { - return null; - } - }); + savedObjectsClient.get = jest.fn().mockImplementation(async (objectType: string) => { + if (objectType === ManifestConstants.SAVED_OBJECT_TYPE) { + return { + attributes: { + created: '20-01-2020 10:00:00.000Z', + schemaVersion: 'v2', + semanticVersion: '1.0.0', + artifacts: [ + { artifactId: ARTIFACT_ID_EXCEPTIONS_MACOS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_LINUX, policyId: undefined }, + { artifactId: ARTIFACT_ID_EXCEPTIONS_WINDOWS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_MACOS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_1 }, + { artifactId: ARTIFACT_ID_TRUSTED_APPS_WINDOWS, policyId: TEST_POLICY_ID_2 }, + ], + }, + version: '2.0.0', + }; + } else { + return null; + } + }); ( manifestManagerContext.artifactClient as jest.Mocked @@ -278,6 +281,9 @@ describe('ManifestManager', () => { ARTIFACT_NAME_EVENT_FILTERS_MACOS, ARTIFACT_NAME_EVENT_FILTERS_WINDOWS, ARTIFACT_NAME_EVENT_FILTERS_LINUX, + ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_MACOS, + ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_WINDOWS, + ARTIFACT_NAME_HOST_ISOLATION_EXCEPTIONS_LINUX, ]; const getArtifactIds = (artifacts: InternalArtifactSchema[]) => [ @@ -310,7 +316,7 @@ describe('ManifestManager', () => { context.savedObjectsClient.create = jest .fn() - .mockImplementation((type: string, object: InternalManifestSchema) => ({ + .mockImplementation((_type: string, object: InternalManifestSchema) => ({ attributes: object, })); const manifest = await manifestManager.buildNewManifest(); @@ -321,7 +327,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(9); + expect(artifacts.length).toBe(12); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); for (const artifact of artifacts) { @@ -336,16 +342,18 @@ describe('ManifestManager', () => { test('Builds fully new manifest if no baseline parameter passed and present exception list items', async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); + const hostIsolationExceptionsItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); const context = buildManifestManagerContextMock({}); const manifestManager = new ManifestManager(context); context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({ [ENDPOINT_LIST_ID]: { macos: [exceptionListItem] }, [ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] }, + [ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] }, }); context.savedObjectsClient.create = jest .fn() - .mockImplementation((type: string, object: InternalManifestSchema) => ({ + .mockImplementation((_type: string, object: InternalManifestSchema) => ({ attributes: object, })); context.packagePolicyService.listIds = mockPolicyListIdsResponse([TEST_POLICY_ID_1]); @@ -358,7 +366,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(9); + expect(artifacts.length).toBe(12); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ @@ -374,6 +382,11 @@ describe('ManifestManager', () => { expect(getArtifactObject(artifacts[6])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[7])).toStrictEqual({ entries: [] }); expect(getArtifactObject(artifacts[8])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[9])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[10])).toStrictEqual({ entries: [] }); + expect(getArtifactObject(artifacts[11])).toStrictEqual({ + entries: translateToEndpointExceptions([hostIsolationExceptionsItem], 'v1'), + }); for (const artifact of artifacts) { expect(manifest.isDefaultArtifact(artifact)).toBe(true); @@ -395,7 +408,7 @@ describe('ManifestManager', () => { context.packagePolicyService.listIds = mockPolicyListIdsResponse([TEST_POLICY_ID_1]); context.savedObjectsClient.create = jest .fn() - .mockImplementation((type: string, object: InternalManifestSchema) => ({ + .mockImplementation((_type: string, object: InternalManifestSchema) => ({ attributes: object, })); const oldManifest = await manifestManager.buildNewManifest(); @@ -413,7 +426,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(9); + expect(artifacts.length).toBe(12); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(artifacts[0]).toStrictEqual(oldManifest.getAllArtifacts()[0]); @@ -462,7 +475,7 @@ describe('ManifestManager', () => { context.savedObjectsClient.create = jest .fn() - .mockImplementation((type: string, object: InternalManifestSchema) => ({ + .mockImplementation((_type: string, object: InternalManifestSchema) => ({ attributes: object, })); @@ -474,7 +487,7 @@ describe('ManifestManager', () => { const artifacts = manifest.getAllArtifacts(); - expect(artifacts.length).toBe(10); + expect(artifacts.length).toBe(13); expect(getArtifactIds(artifacts)).toStrictEqual(SUPPORTED_ARTIFACT_NAMES); expect(getArtifactObject(artifacts[0])).toStrictEqual({ @@ -653,7 +666,7 @@ describe('ManifestManager', () => { context.savedObjectsClient.create = jest .fn() - .mockImplementation((type: string, object: InternalManifestSchema) => object); + .mockImplementation((_type: string, object: InternalManifestSchema) => object); await expect(manifestManager.commit(manifest)).resolves.toBeUndefined(); @@ -690,7 +703,7 @@ describe('ManifestManager', () => { context.savedObjectsClient.update = jest .fn() - .mockImplementation((type: string, id: string, object: InternalManifestSchema) => object); + .mockImplementation((_type: string, _id: string, object: InternalManifestSchema) => object); await expect(manifestManager.commit(manifest)).resolves.toBeUndefined(); @@ -1023,7 +1036,7 @@ describe('ManifestManager', () => { context.savedObjectsClient.create = jest .fn() - .mockImplementation((type: string, object: InternalManifestSchema) => ({ + .mockImplementation((_type: string, object: InternalManifestSchema) => ({ attributes: object, })); const manifest = await manifestManager.buildNewManifest(); @@ -1046,7 +1059,7 @@ describe('ManifestManager', () => { context.savedObjectsClient.create = jest .fn() - .mockImplementation((type: string, object: InternalManifestSchema) => ({ + .mockImplementation((_type: string, object: InternalManifestSchema) => ({ attributes: object, })); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 5c1d327b1b892..736bf1c58cb90 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -26,6 +26,7 @@ import { getEndpointEventFiltersList, getEndpointExceptionList, getEndpointTrustedAppsList, + getHostIsolationExceptionsList, Manifest, } from '../../../lib/artifacts'; import { @@ -237,6 +238,46 @@ export class ManifestManager { ); } + protected async buildHostIsolationExceptionsArtifacts(): Promise { + const defaultArtifacts: InternalArtifactCompleteSchema[] = []; + const policySpecificArtifacts: Record = {}; + + for (const os of ArtifactConstants.SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS) { + defaultArtifacts.push(await this.buildHostIsolationExceptionForOs(os)); + } + + await iterateAllListItems( + (page) => this.listEndpointPolicyIds(page), + async (policyId) => { + for (const os of ArtifactConstants.SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS) { + policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; + policySpecificArtifacts[policyId].push( + await this.buildHostIsolationExceptionForOs(os, policyId) + ); + } + } + ); + + return { defaultArtifacts, policySpecificArtifacts }; + } + + protected async buildHostIsolationExceptionForOs( + os: string, + policyId?: string + ): Promise { + return buildArtifact( + await getHostIsolationExceptionsList( + this.exceptionListClient, + this.schemaVersion, + os, + policyId + ), + this.schemaVersion, + os, + ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME + ); + } + /** * Writes new artifact SO. * @@ -381,6 +422,7 @@ export class ManifestManager { this.buildExceptionListArtifacts(), this.buildTrustedAppsArtifacts(), this.buildEventFiltersArtifacts(), + this.buildHostIsolationExceptionsArtifacts(), ]); const manifest = new Manifest({ diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index 2f31f54143f74..71c093e0781b0 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; + import { httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { createNewPackagePolicyMock, deletePackagePolicyMock } from '../../../fleet/common/mocks'; import { @@ -18,7 +20,8 @@ import { getPackagePolicyUpdateCallback, } from './fleet_integration'; import { KibanaRequest } from 'kibana/server'; -import { createMockConfig, requestContextMock } from '../lib/detection_engine/routes/__mocks__'; +import { requestContextMock } from '../lib/detection_engine/routes/__mocks__'; +import { requestContextFactoryMock } from '../request_context_factory.mock'; import { EndpointAppContextServiceStartContract } from '../endpoint/endpoint_app_context_services'; import { createMockEndpointAppContextServiceStartContract } from '../endpoint/mocks'; import { licenseMock } from '../../../licensing/common/licensing.mock'; @@ -42,16 +45,12 @@ import { ExperimentalFeatures, } from '../../common/experimental_features'; import { DeletePackagePoliciesResponse } from '../../../fleet/common'; -import { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; describe('ingest_integration tests ', () => { let endpointAppContextMock: EndpointAppContextServiceStartContract; let req: KibanaRequest; let ctx: SecuritySolutionRequestHandlerContext; const exceptionListClient: ExceptionListClient = getExceptionListClientMock(); - const maxTimelineImportExportSize = createMockConfig().maxTimelineImportExportSize; - const prebuiltRulesFromFileSystem = createMockConfig().prebuiltRulesFromFileSystem; - const prebuiltRulesFromSavedObjects = createMockConfig().prebuiltRulesFromSavedObjects; let licenseEmitter: Subject; let licenseService: LicenseService; const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } }); @@ -88,11 +87,7 @@ describe('ingest_integration tests ', () => { const callback = getPackagePolicyCreateCallback( logger, manifestManager, - endpointAppContextMock.appClientFactory, - maxTimelineImportExportSize, - prebuiltRulesFromFileSystem, - prebuiltRulesFromSavedObjects, - endpointAppContextMock.security, + requestContextFactoryMock.create(), endpointAppContextMock.alerting, licenseService, exceptionListClient diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts index 09810a6c88c3d..a53d5d43de524 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts @@ -8,7 +8,6 @@ import { KibanaRequest, Logger, RequestHandlerContext } from 'kibana/server'; import { ExceptionListClient } from '../../../lists/server'; import { PluginStartContract as AlertsStartContract } from '../../../alerting/server'; -import { SecurityPluginStart } from '../../../security/server'; import { PostPackagePolicyCreateCallback, PostPackagePolicyDeleteCallback, @@ -18,15 +17,15 @@ import { import { NewPackagePolicy, UpdatePackagePolicy } from '../../../fleet/common'; import { NewPolicyData, PolicyConfig } from '../../common/endpoint/types'; -import { ManifestManager } from '../endpoint/services'; -import { AppClientFactory } from '../client'; +import { ExperimentalFeatures } from '../../common/experimental_features'; import { LicenseService } from '../../common/license'; +import { ManifestManager } from '../endpoint/services'; +import { IRequestContextFactory } from '../request_context_factory'; import { installPrepackagedRules } from './handlers/install_prepackaged_rules'; import { createPolicyArtifactManifest } from './handlers/create_policy_artifact_manifest'; import { createDefaultPolicy } from './handlers/create_default_policy'; import { validatePolicyAgainstLicense } from './handlers/validate_policy_against_license'; import { removePolicyFromTrustedApps } from './handlers/remove_policy_from_trusted_apps'; -import { ExperimentalFeatures } from '../../common/experimental_features'; const isEndpointPackagePolicy = ( packagePolicy: T @@ -40,11 +39,7 @@ const isEndpointPackagePolicy = ( export const getPackagePolicyCreateCallback = ( logger: Logger, manifestManager: ManifestManager, - appClientFactory: AppClientFactory, - maxTimelineImportExportSize: number, - prebuiltRulesFromFileSystem: boolean, - prebuiltRulesFromSavedObjects: boolean, - securityStart: SecurityPluginStart, + securitySolutionRequestContextFactory: IRequestContextFactory, alerts: AlertsStartContract, licenseService: LicenseService, exceptionsClient: ExceptionListClient | undefined @@ -59,20 +54,23 @@ export const getPackagePolicyCreateCallback = ( return newPackagePolicy; } + // In this callback we are handling an HTTP request to the fleet plugin. Since we use + // code from the security_solution plugin to handle it (installPrepackagedRules), + // we need to build the context that is native to security_solution and pass it there. + const securitySolutionContext = await securitySolutionRequestContextFactory.create( + context, + request + ); + // perform these operations in parallel in order to help in not delaying the API response too much const [, manifestValue] = await Promise.all([ // Install Detection Engine prepackaged rules exceptionsClient && installPrepackagedRules({ logger, - appClientFactory, - context, + context: securitySolutionContext, request, - securityStart, alerts, - maxTimelineImportExportSize, - prebuiltRulesFromFileSystem, - prebuiltRulesFromSavedObjects, exceptionsClient, }), diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/install_prepackaged_rules.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/install_prepackaged_rules.ts index d8adf4ea6a1ca..01368ccb22c62 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/install_prepackaged_rules.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/install_prepackaged_rules.ts @@ -5,25 +5,18 @@ * 2.0. */ -import { KibanaRequest, Logger, RequestHandlerContext } from 'kibana/server'; +import { KibanaRequest, Logger } from 'kibana/server'; import { ExceptionListClient } from '../../../../lists/server'; import { PluginStartContract as AlertsStartContract } from '../../../../alerting/server'; -import { SecurityPluginStart } from '../../../../security/server'; -import { AppClientFactory } from '../../client'; import { createDetectionIndex } from '../../lib/detection_engine/routes/index/create_index_route'; import { createPrepackagedRules } from '../../lib/detection_engine/routes/rules/add_prepackaged_rules_route'; -import { buildFrameworkRequest } from '../../lib/timeline/utils/common'; +import { SecuritySolutionApiRequestHandlerContext } from '../../types'; export interface InstallPrepackagedRulesProps { logger: Logger; - appClientFactory: AppClientFactory; - context: RequestHandlerContext; + context: SecuritySolutionApiRequestHandlerContext; request: KibanaRequest; - securityStart: SecurityPluginStart; alerts: AlertsStartContract; - maxTimelineImportExportSize: number; - prebuiltRulesFromFileSystem: boolean; - prebuiltRulesFromSavedObjects: boolean; exceptionsClient: ExceptionListClient; } @@ -33,29 +26,14 @@ export interface InstallPrepackagedRulesProps { */ export const installPrepackagedRules = async ({ logger, - appClientFactory, context, request, - securityStart, alerts, - maxTimelineImportExportSize, - prebuiltRulesFromFileSystem, - prebuiltRulesFromSavedObjects, exceptionsClient, }: InstallPrepackagedRulesProps): Promise => { - // prep for detection rules creation - const appClient = appClientFactory.create(request); - - // This callback is called by fleet plugin. - // It doesn't have access to SecuritySolutionRequestHandlerContext in runtime. - // Muting the error to have green CI. - // @ts-expect-error - const frameworkRequest = await buildFrameworkRequest(context, securityStart, request); - // Create detection index & rules (if necessary). move past any failure, this is just a convenience try { - // @ts-expect-error - await createDetectionIndex(context, appClient); + await createDetectionIndex(context); } catch (err) { if (err.statusCode !== 409) { // 409 -> detection index already exists, which is fine @@ -68,14 +46,8 @@ export const installPrepackagedRules = async ({ // this checks to make sure index exists first, safe to try in case of failure above // may be able to recover from minor errors await createPrepackagedRules( - // @ts-expect-error context, - appClient, alerts.getRulesClientWithRequest(request), - frameworkRequest, - maxTimelineImportExportSize, - prebuiltRulesFromFileSystem, - prebuiltRulesFromSavedObjects, exceptionsClient ); } catch (err) { diff --git a/x-pack/plugins/security_solution/server/index.ts b/x-pack/plugins/security_solution/server/index.ts index 7e3da726f6ebe..0adcd25f5e246 100644 --- a/x-pack/plugins/security_solution/server/index.ts +++ b/x-pack/plugins/security_solution/server/index.ts @@ -7,7 +7,7 @@ import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; import { Plugin, PluginSetup, PluginStart } from './plugin'; -import { configSchema, ConfigType } from './config'; +import { configSchema, ConfigSchema, ConfigType } from './config'; import { SIGNALS_INDEX_KEY } from '../common/constants'; import { AppClient } from './types'; @@ -15,7 +15,7 @@ export const plugin = (context: PluginInitializerContext) => { return new Plugin(context); }; -export const config: PluginConfigDescriptor = { +export const config: PluginConfigDescriptor = { exposeToBrowser: { enableExperimental: true, }, @@ -47,5 +47,5 @@ export const config: PluginConfigDescriptor = { export { ConfigType, Plugin, PluginSetup, PluginStart }; export { AppClient }; -export type { AppRequestContext } from './types'; +export type { SecuritySolutionApiRequestHandlerContext } from './types'; export { EndpointError } from './endpoint/errors'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts index 8417115fb1896..cf0ceaff4ec4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts @@ -5,34 +5,11 @@ * 2.0. */ -import { DEFAULT_SIGNALS_INDEX, SIGNALS_INDEX_KEY } from '../../../../../common/constants'; -import { requestContextMock } from './request_context'; -import { serverMock } from './server'; -import { requestMock } from './request'; -import { responseMock } from './response_factory'; -import { ConfigType } from '../../../../config'; -import { UnderlyingLogClient } from '../../rule_execution_log/types'; - -export { requestMock, requestContextMock, responseMock, serverMock }; - -export const createMockConfig = (): ConfigType => ({ - [SIGNALS_INDEX_KEY]: DEFAULT_SIGNALS_INDEX, - maxRuleImportExportSize: 10000, - maxRuleImportPayloadBytes: 10485760, - maxTimelineImportExportSize: 10000, - maxTimelineImportPayloadBytes: 10485760, - enableExperimental: [], - endpointResultListDefaultFirstPageIndex: 0, - endpointResultListDefaultPageSize: 10, - packagerTaskInterval: '60s', - alertMergeStrategy: 'missingFields', - alertIgnoreFields: [], - prebuiltRulesFromFileSystem: true, - prebuiltRulesFromSavedObjects: false, - ruleExecutionLog: { - underlyingClient: UnderlyingLogClient.savedObjects, - }, -}); +export { requestContextMock } from './request_context'; +export { requestMock } from './request'; +export { responseMock } from './response_factory'; +export { serverMock } from './server'; +export { configMock, createMockConfig } from '../../../../config.mock'; export const mockGetCurrentUser = { user: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index 6039ad6ab6126..2f5f8ac846954 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -5,66 +5,103 @@ * 2.0. */ -import type { SecuritySolutionRequestHandlerContext } from '../../../../types'; -import { - coreMock, - elasticsearchServiceMock, - savedObjectsClientMock, -} from '../../../../../../../../src/core/server/mocks'; +import type { MockedKeys } from '@kbn/utility-types/jest'; +import { coreMock } from 'src/core/server/mocks'; + +import { ActionsApiRequestHandlerContext } from '../../../../../../actions/server'; +import { AlertingApiRequestHandlerContext } from '../../../../../../alerting/server'; import { rulesClientMock } from '../../../../../../alerting/server/mocks'; import { licensingMock } from '../../../../../../licensing/server/mocks'; +import { listMock } from '../../../../../../lists/server/mocks'; +import { ruleRegistryMocks } from '../../../../../../rule_registry/server/mocks'; + import { siemMock } from '../../../../mocks'; +import { createMockConfig } from '../../../../config.mock'; import { ruleExecutionLogClientMock } from '../../rule_execution_log/__mocks__/rule_execution_log_client'; +import { requestMock } from './request'; +import { internalFrameworkRequest } from '../../../framework'; -const createMockClients = () => ({ - rulesClient: rulesClientMock.create(), - licensing: { license: licensingMock.createLicenseMock() }, - clusterClient: elasticsearchServiceMock.createScopedClusterClient(), - savedObjectsClient: savedObjectsClientMock.create(), - ruleExecutionLogClient: ruleExecutionLogClientMock.create(), - appClient: siemMock.createClient(), -}); - -/** - * Adds mocking to the interface so we don't have to cast everywhere - */ -type SecuritySolutionRequestHandlerContextMock = SecuritySolutionRequestHandlerContext & { - core: { - elasticsearch: { - client: { - asCurrentUser: { - updateByQuery: jest.Mock; - search: jest.Mock; - security: { - hasPrivileges: jest.Mock; - }; - }; - }; - }; +import type { + SecuritySolutionApiRequestHandlerContext, + SecuritySolutionRequestHandlerContext, +} from '../../../../types'; + +const createMockClients = () => { + const core = coreMock.createRequestHandlerContext(); + const license = licensingMock.createLicenseMock(); + + return { + core, + clusterClient: core.elasticsearch.client, + savedObjectsClient: core.savedObjects.client, + + licensing: { + ...licensingMock.createRequestHandlerContext({ license }), + license, + }, + lists: { + listClient: listMock.getListClient(), + exceptionListClient: listMock.getExceptionListClient(core.savedObjects.client), + }, + rulesClient: rulesClientMock.create(), + ruleDataService: ruleRegistryMocks.createRuleDataService(), + + config: createMockConfig(), + appClient: siemMock.createClient(), + ruleExecutionLogClient: ruleExecutionLogClientMock.create(), }; }; +type MockClients = ReturnType; + +type SecuritySolutionRequestHandlerContextMock = + MockedKeys & { + core: MockClients['core']; + }; + const createRequestContextMock = ( - clients: ReturnType = createMockClients() + clients: MockClients = createMockClients() ): SecuritySolutionRequestHandlerContextMock => { - const coreContext = coreMock.createRequestHandlerContext(); return { - alerting: { getRulesClient: jest.fn(() => clients.rulesClient) }, - core: { - ...coreContext, - elasticsearch: { - ...coreContext.elasticsearch, - client: clients.clusterClient, - }, - savedObjects: { client: clients.savedObjectsClient }, - }, + core: clients.core, + securitySolution: createSecuritySolutionRequestContextMock(clients), + actions: {} as unknown as jest.Mocked, + alerting: { + getRulesClient: jest.fn(() => clients.rulesClient), + } as unknown as jest.Mocked, licensing: clients.licensing, - securitySolution: { - getAppClient: jest.fn(() => clients.appClient), - getExecutionLogClient: jest.fn(() => clients.ruleExecutionLogClient), - getSpaceId: jest.fn(() => 'default'), + lists: { + getListClient: jest.fn(() => clients.lists.listClient), + getExceptionListClient: jest.fn(() => clients.lists.exceptionListClient), }, - } as unknown as SecuritySolutionRequestHandlerContextMock; + }; +}; + +const createSecuritySolutionRequestContextMock = ( + clients: MockClients +): jest.Mocked => { + const core = clients.core; + const kibanaRequest = requestMock.create(); + + return { + core, + getConfig: jest.fn(() => clients.config), + getFrameworkRequest: jest.fn(() => { + return { + ...kibanaRequest.body, + [internalFrameworkRequest]: kibanaRequest, + context: { core }, + user: { + username: 'mockUser', + }, + }; + }), + getAppClient: jest.fn(() => clients.appClient), + getSpaceId: jest.fn(() => 'default'), + getRuleDataService: jest.fn(() => clients.ruleDataService), + getExecutionLogClient: jest.fn(() => clients.ruleExecutionLogClient), + getExceptionListClient: jest.fn(() => clients.lists.exceptionListClient), + }; }; const createTools = () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 9d1cd3cbca3fb..1520b4da82d8d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { SavedObjectsFindResponse, SavedObjectsFindResult } from 'kibana/server'; +import type { estypes } from '@elastic/elasticsearch'; +import { SavedObjectsFindResponse, SavedObjectsFindResult } from 'src/core/server'; + import { ActionResult } from '../../../../../../actions/server'; import { SignalSearchResponse } from '../../signals/types'; import { @@ -562,6 +564,28 @@ export const getFindBulkResultStatus = (): FindBulkExecutionLogResponse => ({ ], }); +export const getBasicEmptySearchResponse = (): estypes.SearchResponse => ({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { + hits: [], + total: { relation: 'eq', value: 0 }, + max_score: 0, + }, +}); + +export const getBasicNoShardsSearchResponse = (): estypes.SearchResponse => ({ + took: 1, + timed_out: false, + _shards: { total: 0, successful: 0, skipped: 0, failed: 0 }, + hits: { + hits: [], + total: { relation: 'eq', value: 0 }, + max_score: 0, + }, +}); + export const getEmptySignalsResponse = (): SignalSearchResponse => ({ took: 1, timed_out: false, @@ -588,7 +612,7 @@ export const getEmptyEqlSequencesResponse = (): EqlSearchResponse => ({ timed_out: false, }); -export const getSuccessfulSignalUpdateResponse = () => ({ +export const getSuccessfulSignalUpdateResponse = (): estypes.UpdateByQueryResponse => ({ took: 18, timed_out: false, total: 1, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts index 2997f2df30644..09c76344ba8e7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts @@ -16,9 +16,8 @@ import { createBootstrapIndex, } from '@kbn/securitysolution-es-utils'; import type { - AppClient, + SecuritySolutionApiRequestHandlerContext, SecuritySolutionPluginRouter, - SecuritySolutionRequestHandlerContext, } from '../../../../types'; import { DETECTION_ENGINE_INDEX_URL } from '../../../../../common/constants'; import { buildSiemResponse } from '../utils'; @@ -34,15 +33,8 @@ import signalsPolicy from './signals_policy.json'; import { templateNeedsUpdate } from './check_template_version'; import { getIndexVersion } from './get_index_version'; import { isOutdated } from '../../migrations/helpers'; -import { RuleDataPluginService } from '../../../../../../rule_registry/server'; -import { ConfigType } from '../../../../config'; -import { parseExperimentalConfigValue } from '../../../../../common/experimental_features'; -export const createIndexRoute = ( - router: SecuritySolutionPluginRouter, - ruleDataService: RuleDataPluginService, - config: ConfigType -) => { +export const createIndexRoute = (router: SecuritySolutionPluginRouter) => { router.post( { path: DETECTION_ENGINE_INDEX_URL, @@ -53,14 +45,13 @@ export const createIndexRoute = ( }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); - const { ruleRegistryEnabled } = parseExperimentalConfigValue(config.enableExperimental); try { const siemClient = context.securitySolution?.getAppClient(); if (!siemClient) { return siemResponse.error({ statusCode: 404 }); } - await createDetectionIndex(context, siemClient, ruleDataService, ruleRegistryEnabled); + await createDetectionIndex(context.securitySolution); return response.ok({ body: { acknowledged: true } }); } catch (err) { const error = transformError(err); @@ -73,33 +64,21 @@ export const createIndexRoute = ( ); }; -class CreateIndexError extends Error { - public readonly statusCode: number; - constructor(message: string, statusCode: number) { - super(message); - this.statusCode = statusCode; - } -} - export const createDetectionIndex = async ( - context: SecuritySolutionRequestHandlerContext, - siemClient: AppClient, - ruleDataService: RuleDataPluginService, - ruleRegistryEnabled: boolean + context: SecuritySolutionApiRequestHandlerContext ): Promise => { + const config = context.getConfig(); const esClient = context.core.elasticsearch.client.asCurrentUser; - const spaceId = siemClient.getSpaceId(); - - if (!siemClient) { - throw new CreateIndexError('', 404); - } - + const siemClient = context.getAppClient(); + const spaceId = context.getSpaceId(); const index = siemClient.getSignalsIndex(); const indexExists = await getBootstrapIndexExists( context.core.elasticsearch.client.asInternalUser, index ); + const { ruleRegistryEnabled } = config.experimentalFeatures; + // If using the rule registry implementation, we don't want to create new .siem-signals indices - // only create/update resources if there are existing indices if (ruleRegistryEnabled && !indexExists) { @@ -111,7 +90,10 @@ export const createDetectionIndex = async ( if (!policyExists) { await setPolicy(esClient, index, signalsPolicy); } + + const ruleDataService = context.getRuleDataService(); const aadIndexAliasName = ruleDataService.getResourceName(`security.alerts-${spaceId}`); + if (await templateNeedsUpdate({ alias: index, esClient })) { await esClient.indices.putIndexTemplate({ name: index, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts index 7ffa45e2bf7ee..2c2c65f5f78f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts @@ -19,9 +19,9 @@ describe('read_privileges route', () => { server = serverMock.create(); ({ context } = requestContextMock.createTools()); - context.core.elasticsearch.client.asCurrentUser.security.hasPrivileges.mockResolvedValue({ - body: getMockPrivilegesResult(), - }); + context.core.elasticsearch.client.asCurrentUser.security.hasPrivileges.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(getMockPrivilegesResult()) + ); readPrivilegesRoute(server.router, true); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index 26e09d69d3a45..29ceb74e9ba0c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -10,14 +10,13 @@ import { addPrepackagedRulesRequest, getFindResultWithSingleHit, getAlertMock, + getBasicEmptySearchResponse, + getBasicNoShardsSearchResponse, } from '../__mocks__/request_responses'; -import { requestContextMock, serverMock, createMockConfig, mockGetCurrentUser } from '../__mocks__'; +import { configMock, requestContextMock, serverMock } from '../__mocks__'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; -import { SecurityPluginSetup } from '../../../../../../security/server'; import { addPrepackedRulesRoute, createPrepackagedRules } from './add_prepackaged_rules_route'; import { listMock } from '../../../../../../lists/server/mocks'; -import { siemMock } from '../../../../mocks'; -import { FrameworkRequest } from '../../../framework'; import { ExceptionListClient } from '../../../../../../lists/server'; import { installPrepackagedTimelines } from '../../../timeline/routes/prepackaged_timelines/install_prepackaged_timelines'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -76,25 +75,21 @@ describe.each([ ['Legacy', false], ['RAC', true], ])('add_prepackaged_rules_route - %s', (_, isRuleRegistryEnabled) => { - const siemMockClient = siemMock.createClient(); let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - let securitySetup: SecurityPluginSetup; let mockExceptionsClient: ExceptionListClient; const testif = isRuleRegistryEnabled ? test.skip : test; + const defaultConfig = context.securitySolution.getConfig(); beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - securitySetup = { - authc: { - getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), - }, - authz: {}, - } as unknown as SecurityPluginSetup; - mockExceptionsClient = listMock.getExceptionListClient(); + context.securitySolution.getConfig.mockImplementation(() => + configMock.withRuleRegistryEnabled(defaultConfig, isRuleRegistryEnabled) + ); + clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); clients.rulesClient.update.mockResolvedValue( getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) @@ -110,9 +105,9 @@ describe.each([ }); context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + elasticsearchClientMock.createSuccessTransportRequestPromise(getBasicEmptySearchResponse()) ); - addPrepackedRulesRoute(server.router, createMockConfig(), securitySetup, isRuleRegistryEnabled); + addPrepackedRulesRoute(server.router); }); describe('status codes', () => { @@ -138,7 +133,9 @@ describe.each([ test('it returns a 400 if the index does not exist when rule registry not enabled', async () => { const request = addPrepackagedRulesRequest(); context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + getBasicNoShardsSearchResponse() + ) ); const response = await server.inject(request, context); @@ -302,39 +299,22 @@ describe.each([ describe('createPrepackagedRules', () => { test('uses exception lists client from context when available', async () => { - context.lists = { - getExceptionListClient: jest.fn(), - getListClient: jest.fn(), - }; - const config = createMockConfig(); - await createPrepackagedRules( - context, - siemMockClient, + context.securitySolution, clients.rulesClient, - {} as FrameworkRequest, - 1200, - config.prebuiltRulesFromFileSystem, - config.prebuiltRulesFromSavedObjects, mockExceptionsClient ); expect(mockExceptionsClient.createEndpointList).not.toHaveBeenCalled(); - expect(context.lists?.getExceptionListClient).toHaveBeenCalled(); + expect(context.securitySolution.getExceptionListClient).toHaveBeenCalled(); }); test('uses passed in exceptions list client when lists client not available in context', async () => { - const { lists, ...myContext } = context; - const config = createMockConfig(); + context.securitySolution.getExceptionListClient.mockImplementation(() => null); await createPrepackagedRules( - myContext, - siemMockClient, + context.securitySolution, clients.rulesClient, - {} as FrameworkRequest, - 1200, - config.prebuiltRulesFromFileSystem, - config.prebuiltRulesFromSavedObjects, mockExceptionsClient ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index fed34743e220a..50766af669ce7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -9,9 +9,8 @@ import moment from 'moment'; import { transformError, getIndexExists } from '@kbn/securitysolution-es-utils'; import { validate } from '@kbn/securitysolution-io-ts-utils'; import type { - AppClient, + SecuritySolutionApiRequestHandlerContext, SecuritySolutionPluginRouter, - SecuritySolutionRequestHandlerContext, } from '../../../../types'; import { @@ -21,10 +20,6 @@ import { import { importTimelineResultSchema } from '../../../../../common/types/timeline'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../../common/constants'; -import { ConfigType } from '../../../../config'; -import { SetupPlugins } from '../../../../plugin'; -import { buildFrameworkRequest } from '../../../timeline/utils/common'; - import { getLatestPrepackagedRules } from '../../rules/get_prepackaged_rules'; import { installPrepackagedRules } from '../../rules/install_prepacked_rules'; import { updatePrepackagedRules } from '../../rules/update_prepacked_rules'; @@ -35,17 +30,11 @@ import { ruleAssetSavedObjectsClientFactory } from '../../rules/rule_asset/rule_ import { buildSiemResponse } from '../utils'; import { RulesClient } from '../../../../../../alerting/server'; -import { FrameworkRequest } from '../../../framework'; import { ExceptionListClient } from '../../../../../../lists/server'; import { installPrepackagedTimelines } from '../../../timeline/routes/prepackaged_timelines/install_prepackaged_timelines'; -export const addPrepackedRulesRoute = ( - router: SecuritySolutionPluginRouter, - config: ConfigType, - security: SetupPlugins['security'], - isRuleRegistryEnabled: boolean -) => { +export const addPrepackedRulesRoute = (router: SecuritySolutionPluginRouter) => { router.put( { path: DETECTION_ENGINE_PREPACKAGED_URL, @@ -63,7 +52,6 @@ export const addPrepackedRulesRoute = ( }, async (context, _, response) => { const siemResponse = buildSiemResponse(response); - const frameworkRequest = await buildFrameworkRequest(context, security, _); try { const rulesClient = context.alerting?.getRulesClient(); @@ -74,15 +62,9 @@ export const addPrepackedRulesRoute = ( } const validated = await createPrepackagedRules( - context, - siemClient, + context.securitySolution, rulesClient, - frameworkRequest, - config.maxTimelineImportExportSize, - config.prebuiltRulesFromFileSystem, - config.prebuiltRulesFromSavedObjects, - undefined, - isRuleRegistryEnabled + undefined ); return response.ok({ body: validated ?? {} }); } catch (err) { @@ -105,22 +87,26 @@ class PrepackagedRulesError extends Error { } export const createPrepackagedRules = async ( - context: SecuritySolutionRequestHandlerContext, - siemClient: AppClient, + context: SecuritySolutionApiRequestHandlerContext, rulesClient: RulesClient, - frameworkRequest: FrameworkRequest, - maxTimelineImportExportSize: ConfigType['maxTimelineImportExportSize'], - prebuiltRulesFromFileSystem: ConfigType['prebuiltRulesFromFileSystem'], - prebuiltRulesFromSavedObjects: ConfigType['prebuiltRulesFromSavedObjects'], - exceptionsClient?: ExceptionListClient, - isRuleRegistryEnabled?: boolean | undefined + exceptionsClient?: ExceptionListClient ): Promise => { + const config = context.getConfig(); + const frameworkRequest = context.getFrameworkRequest(); const esClient = context.core.elasticsearch.client; const savedObjectsClient = context.core.savedObjects.client; - const exceptionsListClient = - context.lists != null ? context.lists.getExceptionListClient() : exceptionsClient; + const siemClient = context.getAppClient(); + const exceptionsListClient = context.getExceptionListClient() ?? exceptionsClient; const ruleAssetsClient = ruleAssetSavedObjectsClientFactory(savedObjectsClient); - const ruleStatusClient = context.securitySolution.getExecutionLogClient(); + const ruleStatusClient = context.getExecutionLogClient(); + + const { + maxTimelineImportExportSize, + prebuiltRulesFromFileSystem, + prebuiltRulesFromSavedObjects, + experimentalFeatures: { ruleRegistryEnabled }, + } = config; + if (!siemClient || !rulesClient) { throw new PrepackagedRulesError('', 404); } @@ -137,12 +123,12 @@ export const createPrepackagedRules = async ( ); const prepackagedRules = await getExistingPrepackagedRules({ rulesClient, - isRuleRegistryEnabled: isRuleRegistryEnabled ?? false, + isRuleRegistryEnabled: ruleRegistryEnabled, }); const rulesToInstall = getRulesToInstall(latestPrepackagedRules, prepackagedRules); const rulesToUpdate = getRulesToUpdate(latestPrepackagedRules, prepackagedRules); const signalsIndex = siemClient.getSignalsIndex(); - if (!isRuleRegistryEnabled && (rulesToInstall.length !== 0 || rulesToUpdate.length !== 0)) { + if (!ruleRegistryEnabled && (rulesToInstall.length !== 0 || rulesToUpdate.length !== 0)) { const signalsIndexExists = await getIndexExists(esClient.asCurrentUser, signalsIndex); if (!signalsIndexExists) { throw new PrepackagedRulesError( @@ -153,12 +139,7 @@ export const createPrepackagedRules = async ( } await Promise.all( - installPrepackagedRules( - rulesClient, - rulesToInstall, - signalsIndex, - isRuleRegistryEnabled ?? false - ) + installPrepackagedRules(rulesClient, rulesToInstall, signalsIndex, ruleRegistryEnabled) ); const timeline = await installPrepackagedTimelines( maxTimelineImportExportSize, @@ -171,11 +152,12 @@ export const createPrepackagedRules = async ( ); await updatePrepackagedRules( rulesClient, - context.securitySolution.getSpaceId(), + savedObjectsClient, + context.getSpaceId(), ruleStatusClient, rulesToUpdate, signalsIndex, - isRuleRegistryEnabled ?? false + ruleRegistryEnabled ); const prepackagedRulesOutput: PrePackagedRulesAndTimelinesSchema = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index 6f721bb2bb9c5..6dc303d5a266b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -14,6 +14,8 @@ import { getEmptyFindResult, getAlertMock, createBulkMlRuleRequest, + getBasicEmptySearchResponse, + getBasicNoShardsSearchResponse, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesBulkRoute } from './create_rules_bulk_route'; @@ -43,7 +45,7 @@ describe.each([ ); // successful creation context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + elasticsearchClientMock.createSuccessTransportRequestPromise(getBasicEmptySearchResponse()) ); createRulesBulkRoute(server.router, ml, isRuleRegistryEnabled); }); @@ -93,7 +95,9 @@ describe.each([ test('returns an error object if the index does not exist when rule registry not enabled', async () => { context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + getBasicNoShardsSearchResponse() + ) ); const response = await server.inject(getReadBulkRequest(), context); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 59fe5c0ff68a1..010c4b27507bb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -13,6 +13,8 @@ import { getRuleExecutionStatuses, getFindResultWithSingleHit, createMlRuleRequest, + getBasicEmptySearchResponse, + getBasicNoShardsSearchResponse, } from '../__mocks__/request_responses'; import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; import { buildMlAuthz } from '../../../machine_learning/authz'; @@ -44,7 +46,7 @@ describe.each([ clients.ruleExecutionLogClient.find.mockResolvedValue(getRuleExecutionStatuses()); // needed to transform: ; context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + elasticsearchClientMock.createSuccessTransportRequestPromise(getBasicEmptySearchResponse()) ); createRulesRoute(server.router, ml, isRuleRegistryEnabled); }); @@ -103,7 +105,9 @@ describe.each([ describe('unhappy paths', () => { test('it returns a 400 if the index does not exist when rule registry not enabled', async () => { context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + getBasicNoShardsSearchResponse() + ) ); const response = await server.inject(getCreateRequest(), context); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts index e4b99e63cb6c6..c84dd8147ebcc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts @@ -6,6 +6,7 @@ */ import { transformError } from '@kbn/securitysolution-es-utils'; +import { Logger } from 'src/core/server'; import { exportRulesQuerySchema, ExportRulesQuerySchemaDecoded, @@ -24,6 +25,7 @@ import { buildSiemResponse } from '../utils'; export const exportRulesRoute = ( router: SecuritySolutionPluginRouter, config: ConfigType, + logger: Logger, isRuleRegistryEnabled: boolean ) => { router.post( @@ -44,6 +46,7 @@ export const exportRulesRoute = ( async (context, request, response) => { const siemResponse = buildSiemResponse(response); const rulesClient = context.alerting?.getRulesClient(); + const savedObjectsClient = context.core.savedObjects.client; if (!rulesClient) { return siemResponse.error({ statusCode: 404 }); @@ -71,8 +74,14 @@ export const exportRulesRoute = ( const exported = request.body?.objects != null - ? await getExportByObjectIds(rulesClient, request.body.objects, isRuleRegistryEnabled) - : await getExportAll(rulesClient, isRuleRegistryEnabled); + ? await getExportByObjectIds( + rulesClient, + savedObjectsClient, + request.body.objects, + logger, + isRuleRegistryEnabled + ) + : await getExportAll(rulesClient, savedObjectsClient, logger, isRuleRegistryEnabled); const responseBody = request.query.exclude_export_details ? exported.rulesNdjson diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index aa301bcc0335e..23779afdc5410 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -12,6 +12,8 @@ import { getEmptyFindResult, getAlertMock, getFindResultWithSingleHit, + getBasicEmptySearchResponse, + getBasicNoShardsSearchResponse, } from '../__mocks__/request_responses'; import { createMockConfig, requestContextMock, serverMock, requestMock } from '../__mocks__'; import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; @@ -52,7 +54,7 @@ describe.each([ getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) ); context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) + elasticsearchClientMock.createSuccessTransportRequestPromise(getBasicEmptySearchResponse()) ); importRulesRoute(server.router, config, ml, isRuleRegistryEnabled); }); @@ -133,7 +135,9 @@ describe.each([ test('returns an error if the index does not exist when rule registry not enabled', async () => { clients.appClient.getSignalsIndex.mockReturnValue('mockSignalsIndex'); context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) + elasticsearchClientMock.createSuccessTransportRequestPromise( + getBasicNoShardsSearchResponse() + ) ); const response = await server.inject(request, context); expect(response.status).toEqual(isRuleRegistryEnabled ? 200 : 400); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 8269fe8b36132..3752128d3daa3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -40,6 +40,7 @@ import { } from '../utils'; import { patchRules } from '../../rules/patch_rules'; +import { legacyMigrate } from '../../rules/utils'; import { getTupleDuplicateErrorsAndUniqueRules } from './utils'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; @@ -193,6 +194,7 @@ export const importRulesRoute = ( throttle, version, exceptions_list: exceptionsList, + actions, } = parsedRule; try { @@ -264,15 +266,21 @@ export const importRulesRoute = ( note, version, exceptionsList, - actions: [], // Actions are not imported nor exported at this time + actions, }); resolve({ rule_id: ruleId, status_code: 200, }); } else if (rule != null && request.query.overwrite) { + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule, + }); await patchRules({ rulesClient, + savedObjectsClient, author, buildingBlockType, spaceId: context.securitySolution.getSpaceId(), @@ -291,7 +299,7 @@ export const importRulesRoute = ( timelineTitle, meta, filters, - rule, + rule: migratedRule, index, interval, maxSignals, @@ -321,7 +329,7 @@ export const importRulesRoute = ( exceptionsList, anomalyThreshold, machineLearningJobId, - actions: undefined, + actions, }); resolve({ rule_id: ruleId, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 67d68221d846f..2b514ba911091 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -24,6 +24,7 @@ import { transformValidateBulkError } from './validate'; import { patchRules } from '../../rules/patch_rules'; import { readRules } from '../../rules/read_rules'; import { PartialFilter } from '../../types'; +import { legacyMigrate } from '../../rules/utils'; export const patchRulesBulkRoute = ( router: SecuritySolutionPluginRouter, @@ -133,9 +134,16 @@ export const patchRulesBulkRoute = ( throwHttpError(await mlAuthz.validateRuleType(existingRule?.params.type)); } - const rule = await patchRules({ + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, rule: existingRule, + }); + + const rule = await patchRules({ + rule: migratedRule, rulesClient, + savedObjectsClient, author, buildingBlockType, description, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts index cf140f22289de..0096cd2e38180 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -24,6 +24,7 @@ import { buildSiemResponse } from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { readRules } from '../../rules/read_rules'; +import { legacyMigrate } from '../../rules/utils'; import { PartialFilter } from '../../types'; export const patchRulesRoute = ( @@ -134,8 +135,15 @@ export const patchRulesRoute = ( throwHttpError(await mlAuthz.validateRuleType(existingRule?.params.type)); } + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule: existingRule, + }); + const rule = await patchRules({ rulesClient, + savedObjectsClient, author, buildingBlockType, description, @@ -154,7 +162,7 @@ export const patchRulesRoute = ( timelineTitle, meta, filters, - rule: existingRule, + rule: migratedRule, index, interval, maxSignals, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts index 41b909bd718c0..3e85b4898d01c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts @@ -17,6 +17,7 @@ import { import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { performBulkActionRoute } from './perform_bulk_action_route'; import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; +import { loggingSystemMock } from 'src/core/server/mocks'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); @@ -27,15 +28,17 @@ describe.each([ let server: ReturnType; let { clients, context } = requestContextMock.createTools(); let ml: ReturnType; + let logger: ReturnType; beforeEach(() => { server = serverMock.create(); + logger = loggingSystemMock.createLogger(); ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); - performBulkActionRoute(server.router, ml, isRuleRegistryEnabled); + performBulkActionRoute(server.router, ml, logger, isRuleRegistryEnabled); }); describe('status codes', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index 0eba5af4e063a..fb5a2315479da 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -6,6 +6,8 @@ */ import { transformError } from '@kbn/securitysolution-es-utils'; +import { Logger } from 'src/core/server'; + import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas'; import { performBulkActionSchema } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema'; @@ -26,6 +28,7 @@ const BULK_ACTION_RULES_LIMIT = 10000; export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, ml: SetupPlugins['ml'], + logger: Logger, isRuleRegistryEnabled: boolean ) => { router.post( @@ -133,7 +136,9 @@ export const performBulkActionRoute = ( case BulkAction.export: const exported = await getExportByObjectIds( rulesClient, + savedObjectsClient, rules.data.map(({ params }) => ({ rule_id: params.ruleId })), + logger, isRuleRegistryEnabled ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index f7bef76944a97..22e8f6543eb7c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -41,6 +41,8 @@ describe.each([ getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) ); + clients.appClient.getSignalsIndex.mockReturnValue('.siem-signals-test-index'); + updateRulesBulkRoute(server.router, ml, isRuleRegistryEnabled); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 6138690070b62..d8b7e8cb2b724 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -19,6 +19,8 @@ import { getIdBulkError } from './utils'; import { transformValidateBulkError } from './validate'; import { transformBulkError, buildSiemResponse, createBulkErrorObject } from '../utils'; import { updateRules } from '../../rules/update_rules'; +import { legacyMigrate } from '../../rules/utils'; +import { readRules } from '../../rules/read_rules'; export const updateRulesBulkRoute = ( router: SecuritySolutionPluginRouter, @@ -69,10 +71,24 @@ export const updateRulesBulkRoute = ( throwHttpError(await mlAuthz.validateRuleType(payloadRule.type)); + const existingRule = await readRules({ + isRuleRegistryEnabled, + rulesClient, + ruleId: payloadRule.rule_id, + id: payloadRule.id, + }); + + await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule: existingRule, + }); + const rule = await updateRules({ spaceId: context.securitySolution.getSpaceId(), rulesClient, ruleStatusClient, + savedObjectsClient, defaultOutputIndex: siemClient.getSignalsIndex(), ruleUpdate: payloadRule, isRuleRegistryEnabled, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index 7d611f3cccbf2..37df792b421b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -44,7 +44,7 @@ describe.each([ getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()) ); // successful update clients.ruleExecutionLogClient.find.mockResolvedValue([]); // successful transform: ; - + clients.appClient.getSignalsIndex.mockReturnValue('.siem-signals-test-index'); updateRulesRoute(server.router, ml, isRuleRegistryEnabled); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts index 7cfe83093a549..cf443e3293510 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -19,6 +19,8 @@ import { getIdError } from './utils'; import { transformValidate } from './validate'; import { updateRules } from '../../rules/update_rules'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { legacyMigrate } from '../../rules/utils'; +import { readRules } from '../../rules/read_rules'; export const updateRulesRoute = ( router: SecuritySolutionPluginRouter, @@ -59,11 +61,25 @@ export const updateRulesRoute = ( throwHttpError(await mlAuthz.validateRuleType(request.body.type)); const ruleStatusClient = context.securitySolution.getExecutionLogClient(); + + const existingRule = await readRules({ + isRuleRegistryEnabled, + rulesClient, + ruleId: request.body.rule_id, + id: request.body.id, + }); + + await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule: existingRule, + }); const rule = await updateRules({ defaultOutputIndex: siemClient.getSignalsIndex(), isRuleRegistryEnabled, rulesClient, ruleStatusClient, + savedObjectsClient, ruleUpdate: request.body, spaceId: context.securitySolution.getSpaceId(), }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts index c5a30c349d497..366ae607f0ba8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts @@ -469,12 +469,12 @@ describe.each([ describe('transformAlertsToRules', () => { test('given an empty array returns an empty array', () => { - expect(transformAlertsToRules([])).toEqual([]); + expect(transformAlertsToRules([], {})).toEqual([]); }); test('given single alert will return the alert transformed', () => { const result1 = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()); - const transformed = transformAlertsToRules([result1]); + const transformed = transformAlertsToRules([result1], {}); const expected = getOutputRuleAlertForRest(); expect(transformed).toEqual([expected]); }); @@ -485,7 +485,7 @@ describe.each([ result2.id = 'some other id'; result2.params.ruleId = 'some other id'; - const transformed = transformAlertsToRules([result1, result2]); + const transformed = transformAlertsToRules([result1, result2], {}); const expected1 = getOutputRuleAlertForRest(); const expected2 = getOutputRuleAlertForRest(); expected2.id = 'some other id'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts index afc48386a2986..bb2e35d189ca1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts @@ -103,8 +103,11 @@ export const transformAlertToRule = ( return internalRuleToAPIResponse(alert, ruleStatus?.attributes, legacyRuleActions); }; -export const transformAlertsToRules = (alerts: RuleAlertType[]): Array> => { - return alerts.map((alert) => transformAlertToRule(alert)); +export const transformAlertsToRules = ( + alerts: RuleAlertType[], + legacyRuleActions: Record +): Array> => { + return alerts.map((alert) => transformAlertToRule(alert, undefined, legacyRuleActions[alert.id])); }; export const transformFindAlerts = ( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals.test.ts index f6abfc9ebe3d1..07c3bc37e7d72 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals.test.ts @@ -26,11 +26,13 @@ describe('set signal status', () => { beforeEach(() => { server = serverMock.create(); ({ context } = requestContextMock.createTools()); - context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise( getSuccessfulSignalUpdateResponse() ) ); + setSignalsStatusRoute(server.router); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts index f09eb43bf15f1..55624b56e39a0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const RULE_EXECUTION_LOG_PROVIDER = 'rule-execution.security'; +export const RULE_EXECUTION_LOG_PROVIDER = 'securitySolution.ruleExecution'; export const ALERT_SAVED_OBJECT_TYPE = 'alert'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/rule_status_saved_objects_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/rule_status_saved_objects_client.ts index 66b646e96ea53..0026bba24eebe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/rule_status_saved_objects_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/rule_status_saved_objects_client.ts @@ -21,7 +21,7 @@ import { IRuleStatusSOAttributes } from '../../rules/types'; export interface RuleStatusSavedObjectsClient { find: ( - options?: Omit + options: Omit & { ruleId: string } ) => Promise>>; findBulk: (ids: string[], statusesPerId: number) => Promise; create: ( @@ -47,9 +47,14 @@ export const ruleStatusSavedObjectsClientFactory = ( savedObjectsClient: SavedObjectsClientContract ): RuleStatusSavedObjectsClient => ({ find: async (options) => { + const references = { + id: options.ruleId, + type: 'alert', + }; const result = await savedObjectsClient.find({ ...options, type: legacyRuleStatusSavedObjectType, + hasReference: references, }); return result.saved_objects; }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts index 9db7afce62ee4..70db3a768fdb1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts @@ -53,8 +53,7 @@ export class SavedObjectsAdapter implements IRuleExecutionLogClient { perPage: logsCount, sortField: 'statusDate', sortOrder: 'desc', - search: ruleId, - searchFields: ['references.id'], + ruleId, }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index 3ca5960d7d4e1..92e4f0bbb4a5e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -9,21 +9,33 @@ import { getAlertMock, getFindResultWithSingleHit, FindHit, + getEmptySavedObjectsResponse, } from '../routes/__mocks__/request_responses'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; import { getExportAll } from './get_export_all'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; + import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { requestContextMock } from '../routes/__mocks__/request_context'; describe.each([ ['Legacy', false], ['RAC', true], ])('getExportAll - %s', (_, isRuleRegistryEnabled) => { + let logger: ReturnType; + const { clients } = requestContextMock.createTools(); + + beforeEach(async () => { + clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse()); + }); + test('it exports everything from the alerts client', async () => { const rulesClient = rulesClientMock.create(); const result = getFindResultWithSingleHit(isRuleRegistryEnabled); const alert = getAlertMock(isRuleRegistryEnabled, getQueryRuleParams()); + alert.params = { ...alert.params, filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], @@ -35,7 +47,12 @@ describe.each([ result.data = [alert]; rulesClient.find.mockResolvedValue(result); - const exports = await getExportAll(rulesClient, isRuleRegistryEnabled); + const exports = await getExportAll( + rulesClient, + clients.savedObjectsClient, + logger, + isRuleRegistryEnabled + ); const rulesJson = JSON.parse(exports.rulesNdjson); const detailsJson = JSON.parse(exports.exportDetails); expect(rulesJson).toEqual({ @@ -97,7 +114,12 @@ describe.each([ rulesClient.find.mockResolvedValue(findResult); - const exports = await getExportAll(rulesClient, isRuleRegistryEnabled); + const exports = await getExportAll( + rulesClient, + clients.savedObjectsClient, + logger, + isRuleRegistryEnabled + ); expect(exports).toEqual({ rulesNdjson: '', exportDetails: '{"exported_count":0,"missing_rules":[],"missing_rules_count":0}\n', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts index 71079ccefc97a..cbbda5df7e2bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts @@ -7,20 +7,33 @@ import { transformDataToNdjson } from '@kbn/securitysolution-utils'; -import { RulesClient } from '../../../../../alerting/server'; +import { Logger } from 'src/core/server'; +import { RulesClient, AlertServices } from '../../../../../alerting/server'; import { getNonPackagedRules } from './get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { transformAlertsToRules } from '../routes/rules/utils'; +// eslint-disable-next-line no-restricted-imports +import { legacyGetBulkRuleActionsSavedObject } from '../rule_actions/legacy_get_bulk_rule_actions_saved_object'; + export const getExportAll = async ( rulesClient: RulesClient, + savedObjectsClient: AlertServices['savedObjectsClient'], + logger: Logger, isRuleRegistryEnabled: boolean ): Promise<{ rulesNdjson: string; exportDetails: string; }> => { const ruleAlertTypes = await getNonPackagedRules({ rulesClient, isRuleRegistryEnabled }); - const rules = transformAlertsToRules(ruleAlertTypes); + const alertIds = ruleAlertTypes.map((rule) => rule.id); + const legacyActions = await legacyGetBulkRuleActionsSavedObject({ + alertIds, + savedObjectsClient, + logger, + }); + + const rules = transformAlertsToRules(ruleAlertTypes, legacyActions); // We do not support importing/exporting actions. When we do, delete this line of code const rulesWithoutActions = rules.map((rule) => ({ ...rule, actions: [] })); const rulesNdjson = transformDataToNdjson(rulesWithoutActions); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index 740427e44b560..961f2c6a41866 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -10,28 +10,43 @@ import { getAlertMock, getFindResultWithSingleHit, FindHit, + getEmptySavedObjectsResponse, } from '../routes/__mocks__/request_responses'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { requestContextMock } from '../routes/__mocks__/request_context'; describe.each([ ['Legacy', false], ['RAC', true], ])('get_export_by_object_ids - %s', (_, isRuleRegistryEnabled) => { + let logger: ReturnType; + const { clients } = requestContextMock.createTools(); + beforeEach(() => { jest.resetAllMocks(); jest.restoreAllMocks(); jest.clearAllMocks(); + + clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse()); }); + describe('getExportByObjectIds', () => { test('it exports object ids into an expected string with new line characters', async () => { const rulesClient = rulesClientMock.create(); rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getExportByObjectIds(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getExportByObjectIds( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); const exportsObj = { rulesNdjson: JSON.parse(exports.rulesNdjson), exportDetails: JSON.parse(exports.exportDetails), @@ -102,7 +117,13 @@ describe.each([ rulesClient.find.mockResolvedValue(findResult); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getExportByObjectIds(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getExportByObjectIds( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); expect(exports).toEqual({ rulesNdjson: '', exportDetails: @@ -117,7 +138,13 @@ describe.each([ rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getRulesFromObjects( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); const expected: RulesErrors = { exportedCount: 1, missingRules: [], @@ -192,7 +219,13 @@ describe.each([ rulesClient.find.mockResolvedValue(findResult); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getRulesFromObjects( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); const expected: RulesErrors = { exportedCount: 0, missingRules: [{ rule_id: 'rule-1' }], @@ -215,7 +248,13 @@ describe.each([ rulesClient.find.mockResolvedValue(findResult); const objects = [{ rule_id: 'rule-1' }]; - const exports = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled); + const exports = await getRulesFromObjects( + rulesClient, + clients.savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); const expected: RulesErrors = { exportedCount: 0, missingRules: [{ rule_id: 'rule-1' }], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts index 4cf3ad9133a71..8233fe6d4948c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -8,14 +8,20 @@ import { chunk } from 'lodash'; import { transformDataToNdjson } from '@kbn/securitysolution-utils'; +import { Logger } from 'src/core/server'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; -import { RulesClient } from '../../../../../alerting/server'; +import { RulesClient, AlertServices } from '../../../../../alerting/server'; + import { getExportDetailsNdjson } from './get_export_details_ndjson'; + import { isAlertType } from '../rules/types'; import { transformAlertToRule } from '../routes/rules/utils'; import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants'; import { findRules } from './find_rules'; +// eslint-disable-next-line no-restricted-imports +import { legacyGetBulkRuleActionsSavedObject } from '../rule_actions/legacy_get_bulk_rule_actions_saved_object'; + interface ExportSuccessRule { statusCode: 200; rule: Partial; @@ -34,23 +40,32 @@ export interface RulesErrors { export const getExportByObjectIds = async ( rulesClient: RulesClient, + savedObjectsClient: AlertServices['savedObjectsClient'], objects: Array<{ rule_id: string }>, + logger: Logger, isRuleRegistryEnabled: boolean ): Promise<{ rulesNdjson: string; exportDetails: string; }> => { - const rulesAndErrors = await getRulesFromObjects(rulesClient, objects, isRuleRegistryEnabled); - // We do not support importing/exporting actions. When we do, delete this line of code - const rulesWithoutActions = rulesAndErrors.rules.map((rule) => ({ ...rule, actions: [] })); - const rulesNdjson = transformDataToNdjson(rulesWithoutActions); - const exportDetails = getExportDetailsNdjson(rulesWithoutActions, rulesAndErrors.missingRules); + const rulesAndErrors = await getRulesFromObjects( + rulesClient, + savedObjectsClient, + objects, + logger, + isRuleRegistryEnabled + ); + + const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules); + const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules); return { rulesNdjson, exportDetails }; }; export const getRulesFromObjects = async ( rulesClient: RulesClient, + savedObjectsClient: AlertServices['savedObjectsClient'], objects: Array<{ rule_id: string }>, + logger: Logger, isRuleRegistryEnabled: boolean ): Promise => { // If we put more than 1024 ids in one block like "alert.attributes.tags: (id1 OR id2 OR ... OR id1100)" @@ -78,6 +93,13 @@ export const getRulesFromObjects = async ( sortField: undefined, sortOrder: undefined, }); + const alertIds = rules.data.map((rule) => rule.id); + const legacyActions = await legacyGetBulkRuleActionsSavedObject({ + alertIds, + savedObjectsClient, + logger, + }); + const alertsAndErrors = objects.map(({ rule_id: ruleId }) => { const matchingRule = rules.data.find((rule) => rule.params.ruleId === ruleId); if ( @@ -87,7 +109,7 @@ export const getRulesFromObjects = async ( ) { return { statusCode: 200, - rule: transformAlertToRule(matchingRule), + rule: transformAlertToRule(matchingRule, undefined, legacyActions[matchingRule.id]), }; } else { return { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index 1d09e4ca5c508..3626bcd5f127e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -7,6 +7,7 @@ import { PatchRulesOptions } from './types'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; +import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { ruleExecutionLogClientMock } from '../rule_execution_log/__mocks__/rule_execution_log_client'; @@ -15,6 +16,7 @@ export const getPatchRulesOptionsMock = (isRuleRegistryEnabled: boolean): PatchR author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), + savedObjectsClient: savedObjectsClientMock.create(), spaceId: 'default', ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: undefined, @@ -68,6 +70,7 @@ export const getPatchMlRulesOptionsMock = (isRuleRegistryEnabled: boolean): Patc author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), + savedObjectsClient: savedObjectsClientMock.create(), spaceId: 'default', ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: 55, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index c3b7e7288dc57..fd48cd4eebc2c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -37,6 +37,7 @@ class PatchError extends Error { export const patchRules = async ({ rulesClient, + savedObjectsClient, author, buildingBlockType, ruleStatusClient, @@ -191,14 +192,14 @@ export const patchRules = async ({ const newRule = { tags: addTags(tags ?? rule.tags, rule.params.ruleId, rule.params.immutable), - throttle: throttle !== undefined ? transformToAlertThrottle(throttle) : rule.throttle, - notifyWhen: throttle !== undefined ? transformToNotifyWhen(throttle) : rule.notifyWhen, name: calculateName({ updatedName: name, originalName: rule.name }), schedule: { interval: calculateInterval(interval, rule.schedule.interval), }, - actions: actions?.map(transformRuleToAlertAction) ?? rule.actions, params: removeUndefined(nextParams), + actions: actions?.map(transformRuleToAlertAction) ?? rule.actions, + throttle: throttle !== undefined ? transformToAlertThrottle(throttle) : rule.throttle, + notifyWhen: throttle !== undefined ? transformToNotifyWhen(throttle) : rule.notifyWhen, }; const [validated, errors] = validate(newRule, internalRuleUpdate); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 53a83d61da78d..a4ef081154010 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -8,7 +8,12 @@ import { get } from 'lodash/fp'; import { Readable } from 'stream'; -import { SavedObject, SavedObjectAttributes, SavedObjectsFindResult } from 'kibana/server'; +import { + SavedObject, + SavedObjectAttributes, + SavedObjectsClientContract, + SavedObjectsFindResult, +} from 'kibana/server'; import type { MachineLearningJobIdOrUndefined, From, @@ -271,12 +276,14 @@ export interface UpdateRulesOptions { rulesClient: RulesClient; defaultOutputIndex: string; ruleUpdate: UpdateRulesSchema; + savedObjectsClient: SavedObjectsClientContract; } export interface PatchRulesOptions { spaceId: string; ruleStatusClient: IRuleExecutionLogClient; rulesClient: RulesClient; + savedObjectsClient: SavedObjectsClientContract; anomalyThreshold: AnomalyThresholdOrUndefined; author: AuthorOrUndefined; buildingBlockType: BuildingBlockTypeOrUndefined; @@ -323,7 +330,7 @@ export interface PatchRulesOptions { version: VersionOrUndefined; exceptionsList: ListArrayOrUndefined; actions: RuleAlertAction[] | undefined; - rule: SanitizedAlert | null; + rule: SanitizedAlert | null | undefined; namespace?: NamespaceOrUndefined; } @@ -351,3 +358,9 @@ export interface FindRuleOptions { fields: FieldsOrUndefined; sortOrder: SortOrderOrUndefined; } + +export interface LegacyMigrateParams { + rulesClient: RulesClient; + savedObjectsClient: SavedObjectsClientContract; + rule: SanitizedAlert | null | undefined; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts index 7c9f0c9ec67a3..9bd0fe3cef59a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts @@ -6,6 +6,7 @@ */ import { rulesClientMock } from '../../../../../alerting/server/mocks'; +import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; import { updatePrepackagedRules } from './update_prepacked_rules'; import { patchRules } from './patch_rules'; @@ -19,10 +20,12 @@ describe.each([ ])('updatePrepackagedRules - %s', (_, isRuleRegistryEnabled) => { let rulesClient: ReturnType; let ruleStatusClient: ReturnType; + let savedObjectsClient: ReturnType; beforeEach(() => { rulesClient = rulesClientMock.create(); ruleStatusClient = ruleExecutionLogClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); }); it('should omit actions and enabled when calling patchRules', async () => { @@ -40,6 +43,7 @@ describe.each([ await updatePrepackagedRules( rulesClient, + savedObjectsClient, 'default', ruleStatusClient, [{ ...prepackagedRule, actions }], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index d9c2ecd1b5732..dcf43d41e8d78 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -6,6 +6,7 @@ */ import { chunk } from 'lodash/fp'; +import { SavedObjectsClientContract } from 'kibana/server'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; import { RulesClient, PartialAlert } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; @@ -13,6 +14,7 @@ import { readRules } from './read_rules'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { IRuleExecutionLogClient } from '../rule_execution_log/types'; +import { legacyMigrate } from './utils'; /** * How many rules to update at a time is set to 50 from errors coming from @@ -51,6 +53,7 @@ export const UPDATE_CHUNK_SIZE = 50; */ export const updatePrepackagedRules = async ( rulesClient: RulesClient, + savedObjectsClient: SavedObjectsClientContract, spaceId: string, ruleStatusClient: IRuleExecutionLogClient, rules: AddPrepackagedRulesSchemaDecoded[], @@ -61,6 +64,7 @@ export const updatePrepackagedRules = async ( for (const ruleChunk of ruleChunks) { const rulePromises = createPromises( rulesClient, + savedObjectsClient, spaceId, ruleStatusClient, ruleChunk, @@ -82,6 +86,7 @@ export const updatePrepackagedRules = async ( */ export const createPromises = ( rulesClient: RulesClient, + savedObjectsClient: SavedObjectsClientContract, spaceId: string, ruleStatusClient: IRuleExecutionLogClient, rules: AddPrepackagedRulesSchemaDecoded[], @@ -146,10 +151,17 @@ export const createPromises = ( // TODO: Fix these either with an is conversion or by better typing them within io-ts const filters: PartialFilter[] | undefined = filtersObject as PartialFilter[]; + const migratedRule = await legacyMigrate({ + rulesClient, + savedObjectsClient, + rule: existingRule, + }); + // Note: we do not pass down enabled as we do not want to suddenly disable // or enable rules on the user when they were not expecting it if a rule updates return patchRules({ rulesClient, + savedObjectsClient, author, buildingBlockType, description, @@ -160,7 +172,7 @@ export const createPromises = ( language, license, outputIndex, - rule: existingRule, + rule: migratedRule, savedId, spaceId, ruleStatusClient, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts index 58d6cf1fd5e6b..9a7711fcc8987 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts @@ -6,6 +6,7 @@ */ import { rulesClientMock } from '../../../../../alerting/server/mocks'; +import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getUpdateMachineLearningSchemaMock, getUpdateRulesSchemaMock, @@ -16,6 +17,7 @@ export const getUpdateRulesOptionsMock = (isRuleRegistryEnabled: boolean) => ({ spaceId: 'default', rulesClient: rulesClientMock.create(), ruleStatusClient: ruleExecutionLogClientMock.create(), + savedObjectsClient: savedObjectsClientMock.create(), defaultOutputIndex: '.siem-signals-default', ruleUpdate: getUpdateRulesSchemaMock(), isRuleRegistryEnabled, @@ -25,6 +27,7 @@ export const getUpdateMlRulesOptionsMock = (isRuleRegistryEnabled: boolean) => ( spaceId: 'default', rulesClient: rulesClientMock.create(), ruleStatusClient: ruleExecutionLogClientMock.create(), + savedObjectsClient: savedObjectsClientMock.create(), defaultOutputIndex: '.siem-signals-default', ruleUpdate: getUpdateMachineLearningSchemaMock(), isRuleRegistryEnabled, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index f4060f7f831a9..4268ed9014066 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -6,7 +6,7 @@ */ /* eslint-disable complexity */ - +import { validate } from '@kbn/securitysolution-io-ts-utils'; import { DEFAULT_MAX_SIGNALS } from '../../../../common/constants'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { PartialAlert } from '../../../../../alerting/server'; @@ -14,10 +14,18 @@ import { readRules } from './read_rules'; import { UpdateRulesOptions } from './types'; import { addTags } from './add_tags'; import { typeSpecificSnakeToCamel } from '../schemas/rule_converters'; -import { RuleParams } from '../schemas/rule_schemas'; +import { internalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; import { enableRule } from './enable_rule'; import { maybeMute, transformToAlertThrottle, transformToNotifyWhen } from './utils'; +class UpdateError extends Error { + public readonly statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + export const updateRules = async ({ isRuleRegistryEnabled, spaceId, @@ -25,6 +33,7 @@ export const updateRules = async ({ ruleStatusClient, defaultOutputIndex, ruleUpdate, + savedObjectsClient, }: UpdateRulesOptions): Promise | null> => { const existingRule = await readRules({ isRuleRegistryEnabled, @@ -82,9 +91,14 @@ export const updateRules = async ({ notifyWhen: transformToNotifyWhen(ruleUpdate.throttle), }; + const [validated, errors] = validate(newInternalRule, internalRuleUpdate); + if (errors != null || validated === null) { + throw new UpdateError(`Applying update would create invalid rule: ${errors}`, 400); + } + const update = await rulesClient.update({ id: existingRule.id, - data: newInternalRule, + data: validated, }); await maybeMute({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index 4647a4a9951df..a558024a73e34 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -65,6 +65,9 @@ import { RulesClient } from '../../../../../alerting/server'; import { LegacyRuleActions } from '../rule_actions/legacy_types'; import { FullResponseSchema } from '../../../../common/detection_engine/schemas/request'; import { transformAlertToRuleAction } from '../../../../common/detection_engine/transform_actions'; +// eslint-disable-next-line no-restricted-imports +import { legacyRuleActionsSavedObjectType } from '../rule_actions/legacy_saved_object_mappings'; +import { LegacyMigrateParams } from './types'; export const calculateInterval = ( interval: string | undefined, @@ -296,3 +299,62 @@ export const maybeMute = async ({ // Do nothing, no-operation } }; + +/** + * Determines if rule needs to be migrated from legacy actions + * and returns necessary pieces for the updated rule + */ +export const legacyMigrate = async ({ + rulesClient, + savedObjectsClient, + rule, +}: LegacyMigrateParams): Promise | null | undefined> => { + if (rule == null || rule.id == null) { + return rule; + } + /** + * On update / patch I'm going to take the actions as they are, better off taking rules client.find (siem.notification) result + * and putting that into the actions array of the rule, then set the rules onThrottle property, notifyWhen and throttle from null -> actualy value (1hr etc..) + * Then use the rules client to delete the siem.notification + * Then with the legacy Rule Actions saved object type, just delete it. + */ + + // find it using the references array, not params.ruleAlertId + const [siemNotification, legacyRuleActionsSO] = await Promise.all([ + rulesClient.find({ + options: { + hasReference: { + type: 'alert', + id: rule.id, + }, + }, + }), + savedObjectsClient.find({ + type: legacyRuleActionsSavedObjectType, + }), + ]); + + if (siemNotification != null && siemNotification.data.length > 0) { + await Promise.all([ + rulesClient.delete({ id: siemNotification.data[0].id }), + legacyRuleActionsSO != null && legacyRuleActionsSO.saved_objects.length > 0 + ? savedObjectsClient.delete( + legacyRuleActionsSavedObjectType, + legacyRuleActionsSO.saved_objects[0].id + ) + : null, + ]); + const migratedRule = { + ...rule, + actions: siemNotification.data[0].actions, + throttle: siemNotification.data[0].schedule.interval, + notifyWhen: transformToNotifyWhen(siemNotification.data[0].throttle), + }; + await rulesClient.update({ + id: rule.id, + data: migratedRule, + }); + return migratedRule; + } + return rule; +}; 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 169a820392a6e..677a2028acdf7 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 @@ -11,7 +11,7 @@ import { getThreatList, getThreatListCount } from './get_threat_list'; import { CreateThreatSignalsOptions } from './types'; import { createThreatSignal } from './create_threat_signal'; import { SearchAfterAndBulkCreateReturnType } from '../types'; -import { combineConcurrentResults } from './utils'; +import { buildExecutionIntervalValidator, combineConcurrentResults } from './utils'; import { buildThreatEnrichment } from './build_threat_enrichment'; export const createThreatSignals = async ({ @@ -46,6 +46,9 @@ export const createThreatSignals = async ({ const params = ruleSO.attributes.params; logger.debug(buildRuleMessage('Indicator matching rule starting')); const perPage = concurrentSearches * itemsPerSearch; + const verifyExecutionCanProceed = buildExecutionIntervalValidator( + ruleSO.attributes.schedule.interval + ); let results: SearchAfterAndBulkCreateReturnType = { success: true, @@ -99,6 +102,7 @@ export const createThreatSignals = async ({ }); while (threatList.hits.hits.length !== 0) { + verifyExecutionCanProceed(); const chunks = chunk(itemsPerSearch, threatList.hits.hits); logger.debug(buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`)); const concurrentSearchesPerformed = chunks.map>( 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 ec826b44023f6..f029b02127b08 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 @@ -10,6 +10,7 @@ import { sampleSignalHit } from '../__mocks__/es_results'; import { ThreatMatchNamedQuery } from './types'; import { + buildExecutionIntervalValidator, calculateAdditiveMax, calculateMax, calculateMaxLookBack, @@ -712,4 +713,26 @@ describe('utils', () => { }); }); }); + + describe('buildExecutionIntervalValidator', () => { + it('succeeds if the validator is called within the specified interval', () => { + const validator = buildExecutionIntervalValidator('1m'); + expect(() => validator()).not.toThrowError(); + }); + + it('throws an error if the validator is called after the specified interval', async () => { + const validator = buildExecutionIntervalValidator('1s'); + + await new Promise((r) => setTimeout(r, 1001)); + expect(() => validator()).toThrowError( + 'Current rule execution has exceeded its allotted interval (1s) and has been stopped.' + ); + }); + + it('throws an error if the interval cannot be parsed', () => { + expect(() => buildExecutionIntervalValidator('badString')).toThrowError( + 'Unable to parse rule interval (badString); stopping rule execution since allotted duration is undefined' + ); + }); + }); }); 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 4d9fda43f032e..99f6609faec91 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,10 @@ * 2.0. */ +import moment from 'moment'; + import { SearchAfterAndBulkCreateReturnType, SignalSourceHit } from '../types'; +import { parseInterval } from '../utils'; import { ThreatMatchNamedQuery } from './types'; /** @@ -146,3 +149,21 @@ export const decodeThreatMatchNamedQuery = (encoded: string): ThreatMatchNamedQu export const extractNamedQueries = (hit: SignalSourceHit): ThreatMatchNamedQuery[] => hit.matched_queries?.map((match) => decodeThreatMatchNamedQuery(match)) ?? []; + +export const buildExecutionIntervalValidator: (interval: string) => () => void = (interval) => { + const intervalDuration = parseInterval(interval); + + if (intervalDuration == null) { + throw new Error( + `Unable to parse rule interval (${interval}); stopping rule execution since allotted duration is undefined.` + ); + } + + const executionEnd = moment().add(intervalDuration); + return () => { + if (moment().isAfter(executionEnd)) { + const message = `Current rule execution has exceeded its allotted interval (${interval}) and has been stopped.`; + throw new Error(message); + } + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/framework/types.ts b/x-pack/plugins/security_solution/server/lib/framework/types.ts index eceff4b35f74f..3ecd2adf00242 100644 --- a/x-pack/plugins/security_solution/server/lib/framework/types.ts +++ b/x-pack/plugins/security_solution/server/lib/framework/types.ts @@ -5,14 +5,13 @@ * 2.0. */ -import { KibanaRequest } from '../../../../../../src/core/server'; +import { KibanaRequest, RequestHandlerContext } from '../../../../../../src/core/server'; import { AuthenticatedUser } from '../../../../security/common/model'; -import type { SecuritySolutionRequestHandlerContext } from '../../types'; export const internalFrameworkRequest = Symbol('internalFrameworkRequest'); export interface FrameworkRequest extends Pick { [internalFrameworkRequest]: KibanaRequest; - context: SecuritySolutionRequestHandlerContext; + context: RequestHandlerContext; user: AuthenticatedUser | null; } diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.test.ts index 05f3b5373a8de..8bf5213d6a47f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.test.ts @@ -32,17 +32,12 @@ const notes = [ const existingNoteIds = undefined; const isImmutable = true; -jest.mock('moment', () => { - const mockMoment = { - toISOString: jest - .fn() - .mockReturnValueOnce('2020-11-03T11:37:31.655Z') - .mockReturnValue('2020-11-04T11:37:31.655Z'), - subtract: jest.fn(), - }; - mockMoment.subtract.mockReturnValue(mockMoment); - return jest.fn().mockReturnValue(mockMoment); -}); +// System under test uses moment.js under the hood, so we need to mock time. +// Mocking moment via jest.mock('moment') breaks imports of moment in other files. +// Instead, we simply mock Date.now() via jest API and moment starts using it. +// This affects all the tests in this file and doesn't affect tests in other files. +// https://jestjs.io/docs/timer-mocks +jest.useFakeTimers('modern').setSystemTime(new Date('2020-11-04T11:37:31.655Z')); jest.mock('../../../saved_object/timelines', () => ({ persistTimeline: jest.fn().mockResolvedValue({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts index be086732ddcd0..91f8e4153a63b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts @@ -12,15 +12,14 @@ import { Readable } from 'stream'; import { createListStream } from '@kbn/utils'; import { schema } from '@kbn/config-schema'; -import { KibanaRequest } from 'src/core/server'; +import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { formatErrors } from '@kbn/securitysolution-io-ts-utils'; import { SetupPlugins, StartPlugins } from '../../../plugin'; -import type { SecuritySolutionRequestHandlerContext } from '../../../types'; import { FrameworkRequest } from '../../framework'; export const buildFrameworkRequest = async ( - context: SecuritySolutionRequestHandlerContext, + context: RequestHandlerContext, security: StartPlugins['security'] | SetupPlugins['security'] | undefined, request: KibanaRequest ): Promise => { @@ -30,7 +29,7 @@ export const buildFrameworkRequest = async ( return set( 'user', user, - set( + set( 'context.core.savedObjects.client', savedObjectsClient, request diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 0f908d7db8e05..f2aa4927a7688 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -9,45 +9,15 @@ import { Observable } from 'rxjs'; import LRU from 'lru-cache'; import { estypes } from '@elastic/elasticsearch'; -import { - CoreSetup, - CoreStart, - Logger, - Plugin as IPlugin, - PluginInitializerContext, - SavedObjectsClient, -} from '../../../../src/core/server'; -import { - PluginSetup as DataPluginSetup, - PluginStart as DataPluginStart, -} from '../../../../src/plugins/data/server'; -import { - UsageCollectionSetup, - UsageCounter, -} from '../../../../src/plugins/usage_collection/server'; -import { - PluginSetupContract as AlertingSetup, - PluginStartContract as AlertPluginStartContract, -} from '../../alerting/server'; -import { mappingFromFieldMap } from '../../rule_registry/common/mapping_from_field_map'; +import { Logger, SavedObjectsClient } from '../../../../src/core/server'; +import { UsageCounter } from '../../../../src/plugins/usage_collection/server'; -import { PluginStartContract as CasesPluginStartContract } from '../../cases/server'; import { ECS_COMPONENT_TEMPLATE_NAME } from '../../rule_registry/common/assets'; -import { SecurityPluginSetup as SecuritySetup, SecurityPluginStart } from '../../security/server'; -import { - IRuleDataClient, - RuleRegistryPluginSetupContract, - RuleRegistryPluginStartContract, - Dataset, -} from '../../rule_registry/server'; -import { PluginSetupContract as FeaturesSetup } from '../../features/server'; -import { MlPluginSetup as MlSetup } from '../../ml/server'; +import { mappingFromFieldMap } from '../../rule_registry/common/mapping_from_field_map'; +import { IRuleDataClient, Dataset } from '../../rule_registry/server'; import { ListPluginSetup } from '../../lists/server'; -import { EncryptedSavedObjectsPluginSetup as EncryptedSavedObjectsSetup } from '../../encrypted_saved_objects/server'; -import { SpacesPluginSetup as SpacesSetup } from '../../spaces/server'; -import { ILicense, LicensingPluginStart } from '../../licensing/server'; -import { FleetStartContract } from '../../fleet/server'; -import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; +import { ILicense } from '../../licensing/server'; + import { createEqlAlertType, createIndicatorMatchAlertType, @@ -70,7 +40,6 @@ import { SIGNALS_ID, LEGACY_NOTIFICATIONS_ID, QUERY_RULE_TYPE_ID, - DEFAULT_SPACE_ID, INDICATOR_RULE_TYPE_ID, ML_RULE_TYPE_ID, EQL_RULE_TYPE_ID, @@ -89,18 +58,13 @@ import { registerTrustedAppsRoutes } from './endpoint/routes/trusted_apps'; import { securitySolutionSearchStrategyProvider } from './search_strategy/security_solution'; import { TelemetryEventsSender } from './lib/telemetry/sender'; import { TelemetryReceiver } from './lib/telemetry/receiver'; -import { - TelemetryPluginStart, - TelemetryPluginSetup, -} from '../../../../src/plugins/telemetry/server'; import { licenseService } from './lib/license'; import { PolicyWatcher } from './endpoint/lib/policy/license_watch'; -import { parseExperimentalConfigValue } from '../common/experimental_features'; import { migrateArtifactsToFleet } from './endpoint/lib/artifacts/migrate_artifacts_to_fleet'; import aadFieldConversion from './lib/detection_engine/routes/index/signal_aad_mapping.json'; import { alertsFieldMap } from './lib/detection_engine/rule_types/field_maps/alerts'; import { rulesFieldMap } from './lib/detection_engine/rule_types/field_maps/rules'; -import { RuleExecutionLogClient } from './lib/detection_engine/rule_execution_log/rule_execution_log_client'; +import { registerEventLogProvider } from './lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider'; import { getKibanaPrivilegesFeaturePrivileges, getCasesKibanaFeature } from './features'; import { EndpointMetadataService } from './endpoint/services/metadata'; import { CreateRuleOptions } from './lib/detection_engine/rule_types/types'; @@ -109,50 +73,28 @@ import { legacyRulesNotificationAlertType } from './lib/detection_engine/notific // eslint-disable-next-line no-restricted-imports import { legacyIsNotificationAlertExecutor } from './lib/detection_engine/notifications/legacy_types'; import { createSecurityRuleTypeWrapper } from './lib/detection_engine/rule_types/create_security_rule_type_wrapper'; -import { IEventLogClientService, IEventLogService } from '../../event_log/server'; -import { registerEventLogProvider } from './lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider'; -export interface SetupPlugins { - alerting: AlertingSetup; - data: DataPluginSetup; - encryptedSavedObjects?: EncryptedSavedObjectsSetup; - eventLog: IEventLogService; - features: FeaturesSetup; - lists?: ListPluginSetup; - ml?: MlSetup; - ruleRegistry: RuleRegistryPluginSetupContract; - security?: SecuritySetup; - spaces?: SpacesSetup; - taskManager?: TaskManagerSetupContract; - telemetry?: TelemetryPluginSetup; - usageCollection?: UsageCollectionSetup; -} - -export interface StartPlugins { - alerting: AlertPluginStartContract; - cases?: CasesPluginStartContract; - data: DataPluginStart; - eventLog: IEventLogClientService; - fleet?: FleetStartContract; - licensing: LicensingPluginStart; - ruleRegistry: RuleRegistryPluginStartContract; - security: SecurityPluginStart; - taskManager?: TaskManagerStartContract; - telemetry?: TelemetryPluginStart; -} +import { RequestContextFactory } from './request_context_factory'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginSetup {} +import type { + ISecuritySolutionPlugin, + SecuritySolutionPluginSetupDependencies, + SecuritySolutionPluginStartDependencies, + SecuritySolutionPluginCoreSetupDependencies, + SecuritySolutionPluginCoreStartDependencies, + SecuritySolutionPluginSetup, + SecuritySolutionPluginStart, + PluginInitializerContext, +} from './plugin_contract'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginStart {} +export { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; -export class Plugin implements IPlugin { - private readonly logger: Logger; +export class Plugin implements ISecuritySolutionPlugin { + private readonly pluginContext: PluginInitializerContext; private readonly config: ConfigType; - private context: PluginInitializerContext; - private appClientFactory: AppClientFactory; - private setupPlugins?: SetupPlugins; + private readonly logger: Logger; + private readonly appClientFactory: AppClientFactory; + private readonly endpointAppContextService = new EndpointAppContextService(); private readonly telemetryReceiver: TelemetryReceiver; private readonly telemetryEventsSender: TelemetryEventsSender; @@ -167,10 +109,11 @@ export class Plugin implements IPlugin({ max: 3, maxAge: 1000 * 60 * 5 }); this.telemetryEventsSender = new TelemetryEventsSender(this.logger); @@ -179,26 +122,47 @@ export class Plugin implements IPlugin, plugins: SetupPlugins) { + public setup( + core: SecuritySolutionPluginCoreSetupDependencies, + plugins: SecuritySolutionPluginSetupDependencies + ): SecuritySolutionPluginSetup { this.logger.debug('plugin setup'); - this.setupPlugins = plugins; - const config = this.config; - const globalConfig = this.context.config.legacy.get(); + const { pluginContext, config, logger, appClientFactory } = this; + const experimentalFeatures = config.experimentalFeatures; + + appClientFactory.setup({ + getSpaceId: plugins.spaces?.spacesService?.getSpaceId, + config, + }); - const experimentalFeatures = parseExperimentalConfigValue(config.enableExperimental); initSavedObjects(core.savedObjects); initUiSettings(core.uiSettings, experimentalFeatures); + + const eventLogService = plugins.eventLog; + registerEventLogProvider(eventLogService); + + const requestContextFactory = new RequestContextFactory({ config, core, plugins }); + const router = core.http.createRouter(); + core.http.registerRouteHandlerContext( + APP_ID, + (context, request) => requestContextFactory.create(context, request) + ); + const endpointContext: EndpointAppContext = { - logFactory: this.context.logger, + logFactory: pluginContext.logger, service: this.endpointAppContextService, config: (): Promise => Promise.resolve(config), experimentalFeatures, }; + this.endpointAppContextService.setup({ + securitySolutionRequestContextFactory: requestContextFactory, + }); + initUsageCollectors({ core, - kibanaIndex: globalConfig.kibana.index, + kibanaIndex: config.kibanaIndex, signalsIndex: config.signalsIndex, ml: plugins.ml, usageCollection: plugins.usageCollection, @@ -206,29 +170,6 @@ export class Plugin implements IPlugin(); - core.http.registerRouteHandlerContext( - APP_ID, - (context, request, response) => ({ - getAppClient: () => this.appClientFactory.create(request), - getSpaceId: () => plugins.spaces?.spacesService?.getSpaceId(request) || DEFAULT_SPACE_ID, - getExecutionLogClient: () => - new RuleExecutionLogClient({ - savedObjectsClient: context.core.savedObjects.client, - eventLogService, - underlyingClient: config.ruleExecutionLog.underlyingClient, - }), - }) - ); - - this.appClientFactory.setup({ - getSpaceId: plugins.spaces?.spacesService?.getSpaceId, - config, - }); - // TODO: Once we are past experimental phase this check can be removed along with legacy registration of rules const isRuleRegistryEnabled = experimentalFeatures.ruleRegistryEnabled; @@ -265,9 +206,9 @@ export class Plugin implements IPlugin; + +export type SecuritySolutionPluginCoreStartDependencies = CoreStart; + +export type ISecuritySolutionPlugin = Plugin< + SecuritySolutionPluginSetup, + SecuritySolutionPluginStart, + SecuritySolutionPluginSetupDependencies, + SecuritySolutionPluginStartDependencies +>; + +export type { + PluginInitializerContext, + // Legacy type identifiers left for compatibility with the rest of the code: + SecuritySolutionPluginSetupDependencies as SetupPlugins, + SecuritySolutionPluginStartDependencies as StartPlugins, + SecuritySolutionPluginSetup as PluginSetup, + SecuritySolutionPluginStart as PluginStart, +}; diff --git a/x-pack/plugins/security_solution/server/request_context_factory.mock.ts b/x-pack/plugins/security_solution/server/request_context_factory.mock.ts new file mode 100644 index 0000000000000..5621ac8fb26ab --- /dev/null +++ b/x-pack/plugins/security_solution/server/request_context_factory.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { requestContextMock } from './lib/detection_engine/routes/__mocks__'; +import { IRequestContextFactory } from './request_context_factory'; + +export const requestContextFactoryMock = { + create: (): jest.Mocked => ({ + create: jest.fn((context, request) => { + const fullContext = requestContextMock.create(); + const securitySolutionContext = fullContext.securitySolution; + return Promise.resolve(securitySolutionContext); + }), + }), +}; + +export const RequestContextFactoryMock = jest + .fn, []>() + .mockImplementation(requestContextFactoryMock.create); diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts new file mode 100644 index 0000000000000..c2e622bc495c9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; +import { ExceptionListClient } from '../../lists/server'; + +import { DEFAULT_SPACE_ID } from '../common/constants'; +import { AppClientFactory } from './client'; +import { ConfigType } from './config'; +import { RuleExecutionLogClient } from './lib/detection_engine/rule_execution_log/rule_execution_log_client'; +import { buildFrameworkRequest } from './lib/timeline/utils/common'; +import { + SecuritySolutionPluginCoreSetupDependencies, + SecuritySolutionPluginSetupDependencies, +} from './plugin_contract'; +import { SecuritySolutionApiRequestHandlerContext } from './types'; + +export interface IRequestContextFactory { + create( + context: RequestHandlerContext, + request: KibanaRequest + ): Promise; +} + +interface ConstructorOptions { + config: ConfigType; + core: SecuritySolutionPluginCoreSetupDependencies; + plugins: SecuritySolutionPluginSetupDependencies; +} + +export class RequestContextFactory implements IRequestContextFactory { + private readonly appClientFactory: AppClientFactory; + + constructor(private readonly options: ConstructorOptions) { + this.appClientFactory = new AppClientFactory(); + } + + public async create( + context: RequestHandlerContext, + request: KibanaRequest + ): Promise { + const { options, appClientFactory } = this; + const { config, plugins } = options; + const { lists, ruleRegistry, security, spaces } = plugins; + + appClientFactory.setup({ + getSpaceId: plugins.spaces?.spacesService?.getSpaceId, + config, + }); + + const frameworkRequest = await buildFrameworkRequest(context, security, request); + + return { + core: context.core, + + getConfig: () => config, + + getFrameworkRequest: () => frameworkRequest, + + getAppClient: () => appClientFactory.create(request), + + getSpaceId: () => spaces?.spacesService?.getSpaceId(request) || DEFAULT_SPACE_ID, + + getRuleDataService: () => ruleRegistry.ruleDataService, + + getExecutionLogClient: () => + new RuleExecutionLogClient({ + savedObjectsClient: context.core.savedObjects.client, + eventLogService: plugins.eventLog, + underlyingClient: config.ruleExecutionLog.underlyingClient, + }), + + getExceptionListClient: () => { + if (!lists) { + return null; + } + + const username = security?.authc.getCurrentUser(request)?.username || 'elastic'; + return new ExceptionListClient({ + savedObjectsClient: context.core.savedObjects.client, + user: username, + }); + }, + }; + } +} diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index d045c6b129e43..9d31684907f86 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -6,7 +6,6 @@ */ import { Logger } from 'src/core/server'; -import { RuleDataPluginService } from '../../../rule_registry/server'; import { SecuritySolutionPluginRouter } from '../types'; @@ -67,7 +66,6 @@ export const initRoutes = ( hasEncryptionKey: boolean, security: SetupPlugins['security'], ml: SetupPlugins['ml'], - ruleDataService: RuleDataPluginService, logger: Logger, isRuleRegistryEnabled: boolean ) => { @@ -85,18 +83,18 @@ export const initRoutes = ( // TODO: pass isRuleRegistryEnabled to all relevant routes - addPrepackedRulesRoute(router, config, security, isRuleRegistryEnabled); + addPrepackedRulesRoute(router); getPrepackagedRulesStatusRoute(router, config, security, isRuleRegistryEnabled); createRulesBulkRoute(router, ml, isRuleRegistryEnabled); updateRulesBulkRoute(router, ml, isRuleRegistryEnabled); patchRulesBulkRoute(router, ml, isRuleRegistryEnabled); deleteRulesBulkRoute(router, isRuleRegistryEnabled); - performBulkActionRoute(router, ml, isRuleRegistryEnabled); + performBulkActionRoute(router, ml, logger, isRuleRegistryEnabled); createTimelinesRoute(router, config, security); patchTimelinesRoute(router, config, security); importRulesRoute(router, config, ml, isRuleRegistryEnabled); - exportRulesRoute(router, config, isRuleRegistryEnabled); + exportRulesRoute(router, config, logger, isRuleRegistryEnabled); importTimelinesRoute(router, config, security); exportTimelinesRoute(router, config, security); @@ -127,7 +125,7 @@ export const initRoutes = ( // Detection Engine index routes that have the REST endpoints of /api/detection_engine/index // All REST index creation, policy management for spaces - createIndexRoute(router, ruleDataService, config); + createIndexRoute(router); readIndexRoute(router, config); deleteIndexRoute(router); diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index 7822a5b8ba3c5..84643a329573b 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -6,28 +6,35 @@ */ import type { IRouter, RequestHandlerContext } from 'src/core/server'; -import type { ListsApiRequestHandlerContext } from '../../lists/server'; -import type { LicensingApiRequestHandlerContext } from '../../licensing/server'; +import type { ActionsApiRequestHandlerContext } from '../../actions/server'; import type { AlertingApiRequestHandlerContext } from '../../alerting/server'; +import type { LicensingApiRequestHandlerContext } from '../../licensing/server'; +import type { ListsApiRequestHandlerContext, ExceptionListClient } from '../../lists/server'; +import type { IRuleDataService } from '../../rule_registry/server'; import { AppClient } from './client'; -import { RuleExecutionLogClient } from './lib/detection_engine/rule_execution_log/rule_execution_log_client'; -import type { ActionsApiRequestHandlerContext } from '../../actions/server'; +import { ConfigType } from './config'; +import { IRuleExecutionLogClient } from './lib/detection_engine/rule_execution_log/types'; +import { FrameworkRequest } from './lib/framework'; export { AppClient }; -export interface AppRequestContext { +export interface SecuritySolutionApiRequestHandlerContext extends RequestHandlerContext { + getConfig: () => ConfigType; + getFrameworkRequest: () => FrameworkRequest; getAppClient: () => AppClient; getSpaceId: () => string; - getExecutionLogClient: () => RuleExecutionLogClient; + getRuleDataService: () => IRuleDataService; + getExecutionLogClient: () => IRuleExecutionLogClient; + getExceptionListClient: () => ExceptionListClient | null; } -export type SecuritySolutionRequestHandlerContext = RequestHandlerContext & { - securitySolution: AppRequestContext; - licensing: LicensingApiRequestHandlerContext; - alerting: AlertingApiRequestHandlerContext; +export interface SecuritySolutionRequestHandlerContext extends RequestHandlerContext { + securitySolution: SecuritySolutionApiRequestHandlerContext; actions: ActionsApiRequestHandlerContext; + alerting: AlertingApiRequestHandlerContext; + licensing: LicensingApiRequestHandlerContext; lists?: ListsApiRequestHandlerContext; -}; +} export type SecuritySolutionPluginRouter = IRouter; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 5c87d58199df4..8f4d602e26461 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -1,4 +1,3 @@ - { "extends": "../../../tsconfig.base.json", "compilerOptions": { @@ -40,7 +39,7 @@ { "path": "../maps/tsconfig.json" }, { "path": "../ml/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, - { "path": "../security/tsconfig.json"}, - { "path": "../timelines/tsconfig.json"}, + { "path": "../security/tsconfig.json" }, + { "path": "../timelines/tsconfig.json" } ] } diff --git a/x-pack/plugins/snapshot_restore/common/constants.ts b/x-pack/plugins/snapshot_restore/common/constants.ts index b18e118dc5ff6..df13bd4c2f1f0 100644 --- a/x-pack/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/plugins/snapshot_restore/common/constants.ts @@ -20,6 +20,8 @@ export const PLUGIN = { }, }; +export const MAJOR_VERSION = '8.0.0'; + export const API_BASE_PATH = '/api/snapshot_restore/'; export enum REPOSITORY_TYPES { diff --git a/x-pack/plugins/snapshot_restore/public/plugin.ts b/x-pack/plugins/snapshot_restore/public/plugin.ts index bb091a1fd1831..0351716fad5b5 100644 --- a/x-pack/plugins/snapshot_restore/public/plugin.ts +++ b/x-pack/plugins/snapshot_restore/public/plugin.ts @@ -42,52 +42,58 @@ export class SnapshotRestoreUIPlugin { public setup(coreSetup: CoreSetup, plugins: PluginsDependencies): void { const config = this.initializerContext.config.get(); - const { http } = coreSetup; - const { home, management, usageCollection } = plugins; + const { + ui: { enabled: isSnapshotRestoreUiEnabled }, + } = config; - // Initialize services - this.uiMetricService.setup(usageCollection); - textService.setup(i18n); - httpService.setup(http); + if (isSnapshotRestoreUiEnabled) { + const { http } = coreSetup; + const { home, management, usageCollection } = plugins; - management.sections.section.data.registerApp({ - id: PLUGIN.id, - title: i18n.translate('xpack.snapshotRestore.appTitle', { - defaultMessage: 'Snapshot and Restore', - }), - order: 3, - mount: async (params) => { - const { mountManagementSection } = await import('./application/mount_management_section'); - const services = { - uiMetricService: this.uiMetricService, - }; - return await mountManagementSection(coreSetup, services, config, params); - }, - }); + // Initialize services + this.uiMetricService.setup(usageCollection); + textService.setup(i18n); + httpService.setup(http); - if (home) { - home.featureCatalogue.register({ + management.sections.section.data.registerApp({ id: PLUGIN.id, - title: i18n.translate('xpack.snapshotRestore.featureCatalogueTitle', { - defaultMessage: 'Back up and restore', + title: i18n.translate('xpack.snapshotRestore.appTitle', { + defaultMessage: 'Snapshot and Restore', }), - description: i18n.translate('xpack.snapshotRestore.featureCatalogueDescription', { - defaultMessage: - 'Save snapshots to a backup repository, and restore to recover index and cluster state.', - }), - icon: 'storage', - path: '/app/management/data/snapshot_restore', - showOnHomePage: true, - category: FeatureCatalogueCategory.ADMIN, - order: 630, + order: 3, + mount: async (params) => { + const { mountManagementSection } = await import('./application/mount_management_section'); + const services = { + uiMetricService: this.uiMetricService, + }; + return await mountManagementSection(coreSetup, services, config, params); + }, }); - } - plugins.share.url.locators.create( - new SnapshotRestoreLocatorDefinition({ - managementAppLocator: plugins.management.locator, - }) - ); + if (home) { + home.featureCatalogue.register({ + id: PLUGIN.id, + title: i18n.translate('xpack.snapshotRestore.featureCatalogueTitle', { + defaultMessage: 'Back up and restore', + }), + description: i18n.translate('xpack.snapshotRestore.featureCatalogueDescription', { + defaultMessage: + 'Save snapshots to a backup repository, and restore to recover index and cluster state.', + }), + icon: 'storage', + path: '/app/management/data/snapshot_restore', + showOnHomePage: true, + category: FeatureCatalogueCategory.ADMIN, + order: 630, + }); + } + + plugins.share.url.locators.create( + new SnapshotRestoreLocatorDefinition({ + managementAppLocator: plugins.management.locator, + }) + ); + } } public start() {} diff --git a/x-pack/plugins/snapshot_restore/public/types.ts b/x-pack/plugins/snapshot_restore/public/types.ts index b73170ad9d578..c58c942b4bc16 100644 --- a/x-pack/plugins/snapshot_restore/public/types.ts +++ b/x-pack/plugins/snapshot_restore/public/types.ts @@ -7,4 +7,5 @@ export interface ClientConfigType { slm_ui: { enabled: boolean }; + ui: { enabled: boolean }; } diff --git a/x-pack/plugins/snapshot_restore/server/config.ts b/x-pack/plugins/snapshot_restore/server/config.ts index f0ca416ef2032..cc430f4756610 100644 --- a/x-pack/plugins/snapshot_restore/server/config.ts +++ b/x-pack/plugins/snapshot_restore/server/config.ts @@ -4,14 +4,98 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + slm_ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + slm_ui: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - slm_ui: schema.object({ +export type SnapshotRestoreConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { enabled: schema.boolean({ defaultValue: true }), - }), -}); + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + slm_ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type SnapshotRestoreConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + slm_ui: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.snapshot_restore.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'xpack.snapshot_restore.enabled', + level: 'critical', + title: i18n.translate('xpack.snapshotRestore.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.snapshot_restore.enabled" is deprecated', + }), + message: i18n.translate('xpack.snapshotRestore.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Snapshot and Restore UI, use the "xpack.snapshot_restore.ui.enabled" setting instead of "xpack.snapshot_restore.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.snapshotRestore.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.snapshotRestore.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.snapshot_restore.enabled" setting to "xpack.snapshot_restore.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; -export type SnapshotRestoreConfig = TypeOf; +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/snapshot_restore/server/index.ts b/x-pack/plugins/snapshot_restore/server/index.ts index e10bffd6073d2..1e9d2b55aa20b 100644 --- a/x-pack/plugins/snapshot_restore/server/index.ts +++ b/x-pack/plugins/snapshot_restore/server/index.ts @@ -5,16 +5,9 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; +import { PluginInitializerContext } from 'kibana/server'; import { SnapshotRestoreServerPlugin } from './plugin'; -import { configSchema, SnapshotRestoreConfig } from './config'; -export const plugin = (ctx: PluginInitializerContext) => new SnapshotRestoreServerPlugin(ctx); +export { config } from './config'; -export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], - schema: configSchema, - exposeToBrowser: { - slm_ui: true, - }, -}; +export const plugin = (ctx: PluginInitializerContext) => new SnapshotRestoreServerPlugin(ctx); diff --git a/x-pack/plugins/snapshot_restore/server/plugin.ts b/x-pack/plugins/snapshot_restore/server/plugin.ts index 4414e3735959b..d737807ec8dad 100644 --- a/x-pack/plugins/snapshot_restore/server/plugin.ts +++ b/x-pack/plugins/snapshot_restore/server/plugin.ts @@ -28,16 +28,9 @@ export class SnapshotRestoreServerPlugin implements Plugin this.license = new License(); } - public setup( - { http, getStartServices }: CoreSetup, - { licensing, features, security, cloud }: Dependencies - ): void { + public setup({ http }: CoreSetup, { licensing, features, security, cloud }: Dependencies): void { const pluginConfig = this.context.config.get(); - if (!pluginConfig.enabled) { - return; - } - const router = http.createRouter(); this.license.setup( diff --git a/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.ts b/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.ts index 6e9891ecd6d65..d6eb92b944ce1 100644 --- a/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.ts +++ b/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.ts @@ -52,6 +52,15 @@ export function createTaskManagerUsageCollector( }, }, task_type_exclusion: excludeTaskTypes, + failed_tasks: Object.entries(lastMonitoredHealth?.stats.workload?.value.task_types!).reduce( + (numb, [key, val]) => { + if (val.status.failed !== undefined) { + numb += val.status.failed; + } + return numb; + }, + 0 + ), }; }, schema: { @@ -79,6 +88,7 @@ export function createTaskManagerUsageCollector( }, }, task_type_exclusion: { type: 'array', items: { type: 'keyword' } }, + failed_tasks: { type: 'long' }, }, }); } diff --git a/x-pack/plugins/task_manager/server/usage/types.ts b/x-pack/plugins/task_manager/server/usage/types.ts index 0acbfd1d4fab9..f9ac823a58124 100644 --- a/x-pack/plugins/task_manager/server/usage/types.ts +++ b/x-pack/plugins/task_manager/server/usage/types.ts @@ -30,4 +30,5 @@ export interface TaskManagerUsage { p99: number; }; }; + failed_tasks: number; } diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 5bb559c137390..12763e4e26e31 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -11,15 +11,6 @@ "count_total": { "type": "long" }, - "count_active_total": { - "type": "long" - }, - "count_active_alert_history_connectors": { - "type": "long", - "_meta": { - "description": "The total number of preconfigured alert history connectors used by rules." - } - }, "count_by_type": { "properties": { "DYNAMIC_KEY": { @@ -60,6 +51,15 @@ } } }, + "count_active_total": { + "type": "long" + }, + "count_active_alert_history_connectors": { + "type": "long", + "_meta": { + "description": "The total number of preconfigured alert history connectors used by rules." + } + }, "count_active_by_type": { "properties": { "DYNAMIC_KEY": { @@ -99,6 +99,34 @@ "type": "long" } } + }, + "count_active_email_connectors_by_service_type": { + "properties": { + "DYNAMIC_KEY": { + "type": "long" + }, + "exchange_server": { + "type": "long" + }, + "gmail": { + "type": "long" + }, + "outlook365": { + "type": "long" + }, + "elastic_cloud": { + "type": "long" + }, + "other": { + "type": "long" + }, + "ses": { + "type": "long" + } + } + }, + "count_actions_namespaces": { + "type": "long" } } }, @@ -321,6 +349,9 @@ "type": "long" } } + }, + "count_rules_namespaces": { + "type": "long" } } }, @@ -7277,6 +7308,9 @@ "items": { "type": "keyword" } + }, + "failed_tasks": { + "type": "long" } } }, diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts index 5bceb31081687..f9f6a2ea57917 100644 --- a/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/details/index.ts @@ -24,6 +24,7 @@ export interface TimelineEventsDetailsItem { export interface TimelineEventsDetailsStrategyResponse extends IEsSearchResponse { data?: Maybe; inspect?: Maybe; + rawEventData?: Maybe; } export interface TimelineEventsDetailsRequestOptions diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts b/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts index 5371d7004a864..26d32b13eede7 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/height_hack.ts @@ -35,6 +35,11 @@ const DATA_GRID_HEIGHT_BY_PAGE_SIZE: { [key: number]: number } = { * * Please delete me and allow DataGrid to calculate its height when the bug is fixed. */ + +const dataGridRowHeight = 36; +const headerSectionHeight = 32; +const additionalFiltersHeight = 44; + export const useDataGridHeightHack = (pageSize: number, rowCount: number) => { const [height, setHeight] = useState(DATA_GRID_HEIGHT_BY_PAGE_SIZE[pageSize]); @@ -44,7 +49,11 @@ export const useDataGridHeightHack = (pageSize: number, rowCount: number) => { if (rowCount === pageSize) { setHeight(DATA_GRID_HEIGHT_BY_PAGE_SIZE[pageSize]); + } else if (rowCount <= pageSize) { + // This is unnecessary if we add rowCount > pageSize below + setHeight(dataGridRowHeight * rowCount + (headerSectionHeight + additionalFiltersHeight)); } else if ( + // rowCount > pageSize && // This will fix the issue but is always full height so has a lot of empty state gridVirtualized && gridVirtualized.children[0].clientHeight !== gridVirtualized.clientHeight // check if it has vertical scroll ) { diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts index c82d9af938a98..b60add2515ec9 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts @@ -57,10 +57,14 @@ export const timelineEventsDetails: TimelineFactory; + +export type TransformHealthAlertRule = Omit, 'apiKey'>; diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts index f1e7efdadca9d..a478946ff917c 100644 --- a/x-pack/plugins/transform/common/types/transform.ts +++ b/x-pack/plugins/transform/common/types/transform.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; +import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; import type { LatestFunctionConfig, PutTransformsRequestSchema } from '../api_schemas/transforms'; import { isPopulatedObject } from '../shared_imports'; -import { PivotGroupByDict } from './pivot_group_by'; -import { PivotAggDict } from './pivot_aggs'; +import type { PivotGroupByDict } from './pivot_group_by'; +import type { PivotAggDict } from './pivot_aggs'; +import type { TransformHealthAlertRule } from './alerting'; export type IndexName = string; export type IndexPattern = string; @@ -22,6 +23,7 @@ export type TransformBaseConfig = PutTransformsRequestSchema & { id: TransformId; create_time?: number; version?: string; + alerting_rules?: TransformHealthAlertRule[]; }; export interface PivotConfigDefinition { @@ -45,6 +47,11 @@ export type TransformLatestConfig = Omit & { export type TransformConfigUnion = TransformPivotConfig | TransformLatestConfig; +export type ContinuousTransform = Omit & + Required<{ + sync: TransformConfigUnion['sync']; + }>; + export function isPivotTransform(transform: unknown): transform is TransformPivotConfig { return isPopulatedObject(transform, ['pivot']); } @@ -53,6 +60,10 @@ export function isLatestTransform(transform: unknown): transform is TransformLat return isPopulatedObject(transform, ['latest']); } +export function isContinuousTransform(transform: unknown): transform is ContinuousTransform { + return isPopulatedObject(transform, ['sync']); +} + export interface LatestFunctionConfigUI { unique_key: Array> | undefined; sort: EuiComboBoxOptionOption | undefined; diff --git a/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx b/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx new file mode 100644 index 0000000000000..63d00f280f3f3 --- /dev/null +++ b/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, FC, useContext, useMemo } from 'react'; +import { memoize } from 'lodash'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { pluck } from 'rxjs/operators'; +import useObservable from 'react-use/lib/useObservable'; +import { useAppDependencies } from '../app/app_dependencies'; +import { TransformHealthAlertRule, TransformHealthRuleParams } from '../../common/types/alerting'; +import { TRANSFORM_RULE_TYPE } from '../../common'; + +interface TransformAlertFlyoutProps { + initialAlert?: TransformHealthAlertRule | null; + ruleParams?: TransformHealthRuleParams | null; + onSave?: () => void; + onCloseFlyout: () => void; +} + +export const TransformAlertFlyout: FC = ({ + initialAlert, + ruleParams, + onCloseFlyout, + onSave, +}) => { + const { triggersActionsUi } = useAppDependencies(); + + const AlertFlyout = useMemo(() => { + if (!triggersActionsUi) return; + + const commonProps = { + onClose: () => { + onCloseFlyout(); + }, + onSave: async () => { + if (onSave) { + onSave(); + } + }, + }; + + if (initialAlert) { + return triggersActionsUi.getEditAlertFlyout({ + ...commonProps, + initialAlert, + }); + } + + return triggersActionsUi.getAddAlertFlyout({ + ...commonProps, + consumer: 'stackAlerts', + canChangeTrigger: false, + alertTypeId: TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH, + metadata: {}, + initialValues: { + params: ruleParams!, + }, + }); + // deps on id to avoid re-rendering on auto-refresh + }, [triggersActionsUi, initialAlert, ruleParams, onCloseFlyout, onSave]); + + return <>{AlertFlyout}; +}; + +interface AlertRulesManage { + editAlertRule$: Observable; + createAlertRule$: Observable; + setEditAlertRule: (alertRule: TransformHealthAlertRule) => void; + setCreateAlertRule: (transformId: string) => void; + hideAlertFlyout: () => void; +} + +export const getAlertRuleManageContext = memoize(function (): AlertRulesManage { + const ruleState$ = new BehaviorSubject<{ + editAlertRule: null | TransformHealthAlertRule; + createAlertRule: null | TransformHealthRuleParams; + }>({ + editAlertRule: null, + createAlertRule: null, + }); + return { + editAlertRule$: ruleState$.pipe(pluck('editAlertRule')), + createAlertRule$: ruleState$.pipe(pluck('createAlertRule')), + setEditAlertRule: (initialRule) => { + ruleState$.next({ + createAlertRule: null, + editAlertRule: initialRule, + }); + }, + setCreateAlertRule: (transformId: string) => { + ruleState$.next({ + createAlertRule: { includeTransforms: [transformId] }, + editAlertRule: null, + }); + }, + hideAlertFlyout: () => { + ruleState$.next({ + createAlertRule: null, + editAlertRule: null, + }); + }, + }; +}); + +export const AlertRulesManageContext = createContext(getAlertRuleManageContext()); + +export function useAlertRuleFlyout(): AlertRulesManage { + return useContext(AlertRulesManageContext); +} + +export const TransformAlertFlyoutWrapper = () => { + const { editAlertRule$, createAlertRule$, hideAlertFlyout } = useAlertRuleFlyout(); + const editAlertRule = useObservable(editAlertRule$); + const createAlertRule = useObservable(createAlertRule$); + + return editAlertRule || createAlertRule ? ( + + ) : null; +}; diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index 8dc0e277c284d..ab38d05ec9f8f 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -19,6 +19,7 @@ import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; import type { AppDependencies } from '../app_dependencies'; import { MlSharedContext } from './shared_context'; import type { GetMlSharedImportsReturnType } from '../../shared_imports'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public'; const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); @@ -43,6 +44,7 @@ const appDependencies: AppDependencies = { savedObjectsPlugin: savedObjectsPluginMock.createStartContract(), share: { urlGenerators: { getUrlGenerator: jest.fn() } } as unknown as SharePluginStart, ml: {} as GetMlSharedImportsReturnType, + triggersActionsUi: {} as jest.Mocked, }; export const useAppDependencies = () => { diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index d3f356f3e83b3..da1178e395720 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -16,6 +16,7 @@ import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { Storage } from '../../../../../src/plugins/kibana_utils/public'; import type { GetMlSharedImportsReturnType } from '../shared_imports'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; export interface AppDependencies { application: CoreStart['application']; @@ -34,6 +35,7 @@ export interface AppDependencies { share: SharePluginStart; ml: GetMlSharedImportsReturnType; spaces?: SpacesPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export const useAppDependencies = () => { diff --git a/x-pack/plugins/transform/public/app/common/transform_list.ts b/x-pack/plugins/transform/public/app/common/transform_list.ts index d73018e284a8c..c8ddd32f6a8ff 100644 --- a/x-pack/plugins/transform/public/app/common/transform_list.ts +++ b/x-pack/plugins/transform/public/app/common/transform_list.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { EuiTableActionsColumnType } from '@elastic/eui'; - -import { TransformConfigUnion, TransformId } from '../../../common/types/transform'; -import { TransformStats } from '../../../common/types/transform_stats'; +import type { EuiTableActionsColumnType } from '@elastic/eui'; +import type { TransformConfigUnion, TransformId } from '../../../common/types/transform'; +import type { TransformStats } from '../../../common/types/transform_stats'; +import type { TransformHealthAlertRule } from '../../../common/types/alerting'; // Used to pass on attribute names to table columns export enum TRANSFORM_LIST_COLUMN { @@ -21,6 +21,7 @@ export interface TransformListRow { config: TransformConfigUnion; mode?: string; // added property on client side to allow filtering by this field stats: TransformStats; + alerting_rules?: TransformHealthAlertRule[]; } // The single Action type is not exported as is diff --git a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts index 2d3425dfeedca..7879e15118a33 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts @@ -87,6 +87,7 @@ export const useGetTransforms = ( mode: typeof config.sync !== 'undefined' ? TRANSFORM_MODE.CONTINUOUS : TRANSFORM_MODE.BATCH, stats, + alerting_rules: config.alerting_rules, }); return reducedtableRows; }, [] as TransformListRow[]); diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx index 875c0f60969ed..cc6313bf058c6 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx @@ -20,12 +20,14 @@ interface Authorization { capabilities: Capabilities; } -const initialCapabalities: Capabilities = { +const initialCapabilities: Capabilities = { canGetTransform: false, canDeleteTransform: false, canPreviewTransform: false, canCreateTransform: false, canStartStopTransform: false, + canCreateTransformAlerts: false, + canUseTransformAlerts: false, }; const initialValue: Authorization = { @@ -35,7 +37,7 @@ const initialValue: Authorization = { hasAllPrivileges: false, missingPrivileges: {}, }, - capabilities: initialCapabalities, + capabilities: initialCapabilities, }; export const AuthorizationContext = createContext({ ...initialValue }); @@ -58,7 +60,7 @@ export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) = const value = { isLoading, privileges: isLoading ? { ...initialValue.privileges } : privilegesData, - capabilities: { ...initialCapabalities }, + capabilities: { ...initialCapabilities }, apiError: error ? (error as Error) : null, }; @@ -85,6 +87,10 @@ export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) = hasPrivilege(['cluster', 'cluster:admin/transform/start_task']) && hasPrivilege(['cluster', 'cluster:admin/transform/stop']); + value.capabilities.canCreateTransformAlerts = value.capabilities.canCreateTransform; + + value.capabilities.canUseTransformAlerts = value.capabilities.canGetTransform; + return ( {children} ); diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts index d059f73a76137..d430a4d059e5c 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts @@ -16,6 +16,8 @@ export interface Capabilities { canPreviewTransform: boolean; canCreateTransform: boolean; canStartStopTransform: boolean; + canCreateTransformAlerts: boolean; + canUseTransformAlerts: boolean; } export type Privilege = [string, string]; @@ -67,6 +69,14 @@ export function createCapabilityFailureMessage( defaultMessage: 'You do not have permission to create transforms.', }); break; + case 'canCreateTransformAlerts': + message = i18n.translate( + 'xpack.transform.capability.noPermission.canCreateTransformAlertsTooltip', + { + defaultMessage: 'You do not have permission to create transform alert rules.', + } + ); + break; case 'canStartStopTransform': message = i18n.translate( 'xpack.transform.capability.noPermission.startOrStopTransformTooltip', diff --git a/x-pack/plugins/transform/public/app/mount_management_section.ts b/x-pack/plugins/transform/public/app/mount_management_section.ts index 1747330818547..6e63094064584 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -29,7 +29,7 @@ export async function mountManagementSection( const startServices = await getStartServices(); const [core, plugins] = startServices; const { application, chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; - const { data, share, spaces } = plugins; + const { data, share, spaces, triggersActionsUi } = plugins; const { docTitle } = chrome; // Initialize services @@ -55,6 +55,7 @@ export async function mountManagementSection( share, spaces, ml: await getMlSharedImports(), + triggersActionsUi, }; const unmountAppCallback = renderApp(element, appDependencies); diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index 8aecf403186c5..218edb95c5f4f 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; -import { TransformPivotConfig } from '../../../../common/types/transform'; +import { TransformConfigUnion } from '../../../../common/types/transform'; import { isHttpFetchError } from '../../common/request'; import { useApi } from '../../hooks/use_api'; @@ -50,7 +50,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { const transformId = match.params.transformId; - const [transformConfig, setTransformConfig] = useState(); + const [transformConfig, setTransformConfig] = useState(); const [errorMessage, setErrorMessage] = useState(); const [isInitialized, setIsInitialized] = useState(false); const { error: searchItemsError, searchItems, setSavedObjectId } = useSearchItems(undefined); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 7ccf986d5d497..859ea77ea5a14 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -23,6 +23,7 @@ import { EuiText, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; import { @@ -52,7 +53,8 @@ import { } from '../../../../../../common/api_schemas/transforms'; import type { RuntimeField } from '../../../../../../../../../src/plugins/data/common'; import { isPopulatedObject } from '../../../../../../common/shared_imports'; -import { isLatestTransform } from '../../../../../../common/types/transform'; +import { isContinuousTransform, isLatestTransform } from '../../../../../../common/types/transform'; +import { TransformAlertFlyout } from '../../../../../alerting/transform_alerting_flyout'; export interface StepDetailsExposedState { created: boolean; @@ -86,6 +88,7 @@ export const StepCreateForm: FC = React.memo( const [loading, setLoading] = useState(false); const [created, setCreated] = useState(defaults.created); const [started, setStarted] = useState(defaults.started); + const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId); const [progressPercentComplete, setProgressPercentComplete] = useState( undefined @@ -398,6 +401,31 @@ export const StepCreateForm: FC = React.memo( )} + {isContinuousTransform(transformConfig) && created ? ( + + + + + + + + + {i18n.translate('xpack.transform.stepCreateForm.createAlertRuleDescription', { + defaultMessage: + 'Opens a wizard to create an alert rule for monitoring transform health.', + })} + + + + ) : null} = React.memo( {i18n.translate('xpack.transform.stepCreateForm.createTransformDescription', { defaultMessage: - 'Create the transform without starting it. You will be able to start the transform later by returning to the transforms list.', + 'Creates the transform without starting it. You will be able to start the transform later by returning to the transforms list.', })} @@ -535,6 +563,12 @@ export const StepCreateForm: FC = React.memo( )} + {alertFlyoutVisible ? ( + + ) : null} ); } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts index fbe32e9bea12f..39b1a2de26f8e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform'; +import type { TransformConfigUnion, TransformId } from '../../../../../../common/types/transform'; export type EsIndexName = string; export type IndexPatternTitle = string; @@ -55,7 +55,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { export function applyTransformConfigToDetailsState( state: StepDetailsExposedState, - transformConfig?: TransformPivotConfig + transformConfig?: TransformConfigUnion ): StepDetailsExposedState { // apply the transform configuration to wizard DETAILS state if (transformConfig !== undefined) { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 0d39ec77d059f..7a47cc539c4aa 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -29,7 +29,7 @@ import { isEsIndices, isPostTransformsPreviewResponseSchema, } from '../../../../../../common/api_schemas/type_guards'; -import { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform'; +import { TransformId } from '../../../../../../common/types/transform'; import { isValidIndexName } from '../../../../../../common/utils/es_utils'; import { getErrorMessage } from '../../../../../../common/utils/errors'; @@ -158,7 +158,7 @@ export const StepDetailsForm: FC = React.memo( ), }); } else { - setTransformIds(resp.transforms.map((transform: TransformPivotConfig) => transform.id)); + setTransformIds(resp.transforms.map((transform) => transform.id)); } const indices = await api.getEsIndices(); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 63e21e5d8aa14..27c43ed01a934 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiSteps, EuiStepStatus } from '@elastic/eui'; -import { TransformPivotConfig } from '../../../../../../common/types/transform'; +import type { TransformConfigUnion } from '../../../../../../common/types/transform'; import { getCreateTransformRequestBody } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; @@ -81,7 +81,7 @@ const StepDefine: FC = ({ }; interface WizardProps { - cloneConfig?: TransformPivotConfig; + cloneConfig?: TransformConfigUnion; searchItems: SearchItems; } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx new file mode 100644 index 0000000000000..c8d67a86d579a --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { EuiToolTip } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { createCapabilityFailureMessage } from '../../../../lib/authorization'; + +interface CreateAlertRuleActionProps { + disabled: boolean; +} + +export const crateAlertRuleActionNameText = i18n.translate( + 'xpack.transform.transformList.createAlertRuleNameText', + { + defaultMessage: 'Create alert rule', + } +); + +export const CreateAlertRuleActionName: FC = ({ disabled }) => { + if (disabled) { + return ( + + <>{crateAlertRuleActionNameText} + + ); + } + + return <>{crateAlertRuleActionNameText}; +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/index.ts new file mode 100644 index 0000000000000..80999d774bdcb --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/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 { useCreateAlertRuleAction } from './use_create_alert_rule_action'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx new file mode 100644 index 0000000000000..070f1eb08ac60 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useContext, useMemo } from 'react'; +import { AuthorizationContext } from '../../../../lib/authorization'; +import { TransformListAction, TransformListRow } from '../../../../common'; +import { + crateAlertRuleActionNameText, + CreateAlertRuleActionName, +} from './create_alert_rule_action_name'; +import { useAlertRuleFlyout } from '../../../../../alerting/transform_alerting_flyout'; +import { isContinuousTransform } from '../../../../../../common/types/transform'; + +export type CreateAlertRuleAction = ReturnType; +export const useCreateAlertRuleAction = (forceDisable: boolean) => { + const { canCreateTransformAlerts } = useContext(AuthorizationContext).capabilities; + const { setCreateAlertRule } = useAlertRuleFlyout(); + + const clickHandler = useCallback( + (item: TransformListRow) => { + setCreateAlertRule(item.id); + }, + [setCreateAlertRule] + ); + + const action: TransformListAction = useMemo( + () => ({ + name: (item: TransformListRow) => ( + + ), + available: (item: TransformListRow) => isContinuousTransform(item.config), + enabled: () => canCreateTransformAlerts && !forceDisable, + description: crateAlertRuleActionNameText, + type: 'icon', + icon: 'bell', + onClick: clickHandler, + 'data-test-subj': 'transformActionCreateAlertRule', + }), + [canCreateTransformAlerts, forceDisable, clickHandler] + ); + + return { action }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx index bccd3aff72c58..af85049ce6915 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx @@ -22,6 +22,7 @@ import { getMlSharedImports } from '../../../../../shared_imports'; // FLAKY https://github.com/elastic/kibana/issues/112922 describe.skip('Transform: Transform List ', () => { + const onAlertEdit = jest.fn(); // Set timezone to US/Eastern for consistent test results. beforeEach(() => { moment.tz.setDefault('US/Eastern'); @@ -38,7 +39,7 @@ describe.skip('Transform: Transform List ', () => { render( - + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx index dff2ba17cb3f0..84110e67d701e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx @@ -7,17 +7,18 @@ import React, { FC } from 'react'; -import { EuiTabbedContent } from '@elastic/eui'; +import { EuiButtonEmpty, EuiTabbedContent } from '@elastic/eui'; import { Optional } from '@kbn/utility-types'; import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import { TransformListRow } from '../../../../common'; import { useAppDependencies } from '../../../../app_dependencies'; -import { ExpandedRowDetailsPane, SectionConfig } from './expanded_row_details_pane'; +import { ExpandedRowDetailsPane, SectionConfig, SectionItem } from './expanded_row_details_pane'; import { ExpandedRowJsonPane } from './expanded_row_json_pane'; import { ExpandedRowMessagesPane } from './expanded_row_messages_pane'; import { ExpandedRowPreviewPane } from './expanded_row_preview_pane'; +import { TransformHealthAlertRule } from '../../../../../../common/types/alerting'; function getItemDescription(value: any) { if (typeof value === 'object') { @@ -44,18 +45,16 @@ export function stringHash(str: string): number { return hash < 0 ? hash * -2 : hash; } -interface Item { - title: string; - description: any; -} +type Item = SectionItem; interface Props { item: TransformListRow; + onAlertEdit: (alertRule: TransformHealthAlertRule) => void; } type StateValues = Optional; -export const ExpandedRow: FC = ({ item }) => { +export const ExpandedRow: FC = ({ item, onAlertEdit }) => { const { ml: { formatHumanReadableDateTimeSeconds }, } = useAppDependencies(); @@ -166,12 +165,40 @@ export const ExpandedRow: FC = ({ item }) => { } } + const alertRuleItems: Item[] | undefined = item.alerting_rules?.map((rule) => { + return { + title: ( + { + onAlertEdit(rule); + }} + flush="left" + size={'xs'} + iconSize={'s'} + > + {rule.name} + + ), + description: rule.executionStatus.status, + }; + }); + const checkpointing: SectionConfig = { title: 'Checkpointing', items: checkpointingItems, position: 'right', }; + const alertingRules: SectionConfig = { + title: i18n.translate('xpack.transform.transformList.transformDetails.alertRulesTitle', { + defaultMessage: 'Alert rules', + }), + items: alertRuleItems!, + position: 'right', + }; + const stats: SectionConfig = { title: 'Stats', items: Object.entries(item.stats.stats).map((s) => { @@ -192,7 +219,16 @@ export const ExpandedRow: FC = ({ item }) => { defaultMessage: 'Details', } ), - content: , + content: ( + + ), }, { id: `transform-stats-tab-${tabId}`, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx index 03e2fb2115d62..1b2dde0a2e576 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx @@ -17,9 +17,10 @@ import { } from '@elastic/eui'; export interface SectionItem { - title: string; - description: string; + title: string | JSX.Element; + description: string | number | JSX.Element; } + export interface SectionConfig { title: string; position: 'left' | 'right'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index ab30f4793a315..8b7aaf1cf8fd2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -50,15 +50,18 @@ import { useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; import { transformFilters, filterTransforms } from './transform_search_bar_filters'; import { useTableSettings } from './use_table_settings'; +import { useAlertRuleFlyout } from '../../../../../alerting/transform_alerting_flyout'; +import { TransformHealthAlertRule } from '../../../../../../common/types/alerting'; function getItemIdToExpandedRowMap( itemIds: TransformId[], - transforms: TransformListRow[] + transforms: TransformListRow[], + onAlertEdit: (alertRule: TransformHealthAlertRule) => void ): ItemIdToExpandedRowMap { return itemIds.reduce((m: ItemIdToExpandedRowMap, transformId: TransformId) => { const item = transforms.find((transform) => transform.config.id === transformId); if (item !== undefined) { - m[transformId] = ; + m[transformId] = ; } return m; }, {} as ItemIdToExpandedRowMap); @@ -79,6 +82,7 @@ export const TransformList: FC = ({ }) => { const [isLoading, setIsLoading] = useState(false); const { refresh } = useRefreshTransformList({ isLoading: setIsLoading }); + const { setEditAlertRule } = useAlertRuleFlyout(); const [filterActive, setFilterActive] = useState(false); @@ -171,7 +175,11 @@ export const TransformList: FC = ({ ); } - const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, transforms); + const itemIdToExpandedRowMap = getItemIdToExpandedRowMap( + expandedRowItemIds, + transforms, + setEditAlertRule + ); const bulkActionMenuItems = [
diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx index b7d5a2b7104ae..20d2f784a4d8b 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx @@ -27,6 +27,7 @@ describe('Transform: Transform List Actions', () => { // in the runtime result here anyway. expect(actions.map((a: any) => a['data-test-subj'])).toStrictEqual([ 'transformActionDiscover', + 'transformActionCreateAlertRule', 'transformActionStart', 'transformActionStop', 'transformActionEdit', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index 81e51cdafc32e..40b40cfa8c7ba 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -18,6 +18,7 @@ import { EditTransformFlyout } from '../edit_transform_flyout'; import { useEditAction } from '../action_edit'; import { useStartAction, StartActionModal } from '../action_start'; import { useStopAction } from '../action_stop'; +import { useCreateAlertRuleAction } from '../action_create_alert'; export const useActions = ({ forceDisable, @@ -35,6 +36,7 @@ export const useActions = ({ const editAction = useEditAction(forceDisable, transformNodes); const startAction = useStartAction(forceDisable, transformNodes); const stopAction = useStopAction(forceDisable); + const createAlertRuleAction = useCreateAlertRuleAction(forceDisable); return { modals: ( @@ -52,6 +54,7 @@ export const useActions = ({ ), actions: [ discoverAction.action, + createAlertRuleAction.action, startAction.action, stopAction.action, editAction.action, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx index af2325ede2021..a26ccf0348c9a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx @@ -20,14 +20,15 @@ describe('Transform: Job List Columns', () => { const columns: ReturnType['columns'] = result.current.columns; - expect(columns).toHaveLength(8); + expect(columns).toHaveLength(9); expect(columns[0].isExpander).toBeTruthy(); expect(columns[1].name).toBe('ID'); - expect(columns[2].name).toBe('Description'); - expect(columns[3].name).toBe('Type'); - expect(columns[4].name).toBe('Status'); - expect(columns[5].name).toBe('Mode'); - expect(columns[6].name).toBe('Progress'); - expect(columns[7].name).toBe('Actions'); + expect(columns[2].id).toBe('alertRule'); + expect(columns[3].name).toBe('Description'); + expect(columns[4].name).toBe('Type'); + expect(columns[5].name).toBe('Status'); + expect(columns[6].name).toBe('Mode'); + expect(columns[7].name).toBe('Progress'); + expect(columns[8].name).toBe('Actions'); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx index dbdd3409c7e34..bad42c212293d 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx @@ -21,6 +21,7 @@ import { EuiText, EuiToolTip, RIGHT_ALIGNMENT, + EuiIcon, } from '@elastic/eui'; import { @@ -95,6 +96,7 @@ export const useColumns = ( const columns: [ EuiTableComputedColumnType, EuiTableFieldDataColumnType, + EuiTableComputedColumnType, EuiTableFieldDataColumnType, EuiTableComputedColumnType, EuiTableComputedColumnType, @@ -143,6 +145,38 @@ export const useColumns = ( truncateText: true, scope: 'row', }, + { + id: 'alertRule', + name: ( + +

+ +

+
+ ), + width: '30px', + render: (item) => { + return Array.isArray(item.alerting_rules) ? ( + + } + > + + + ) : ( + + ); + }, + }, { field: TRANSFORM_LIST_COLUMN.DESCRIPTION, 'data-test-subj': 'transformListColumnDescription', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 2479d34f1579a..055e1e50701f8 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -35,6 +35,11 @@ import { useRefreshInterval } from './components/transform_list/use_refresh_inte import { SearchSelection } from './components/search_selection'; import { TransformList } from './components/transform_list'; import { TransformStatsBar } from './components/transform_list/transforms_stats_bar'; +import { + AlertRulesManageContext, + getAlertRuleManageContext, + TransformAlertFlyoutWrapper, +} from '../../../alerting/transform_alerting_flyout'; export const TransformManagement: FC = () => { const { esTransform } = useDocumentationLinks(); @@ -149,12 +154,15 @@ export const TransformManagement: FC = () => { )} {typeof errorMessage === 'undefined' && ( - + + + + )} )} diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index da280452c1f0f..a7d0dce256640 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -16,7 +16,7 @@ import type { SharePluginStart } from 'src/plugins/share/public'; import type { SpacesApi } from '../../spaces/public'; import { registerFeature } from './register_feature'; import type { PluginSetupContract as AlertingSetup } from '../../alerting/public'; -import type { TriggersAndActionsUIPublicPluginSetup } from '../../triggers_actions_ui/public'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../triggers_actions_ui/public'; import { getTransformHealthRuleType } from './alerting'; export interface PluginsDependencies { @@ -27,7 +27,7 @@ export interface PluginsDependencies { share: SharePluginStart; spaces?: SpacesApi; alerting?: AlertingSetup; - triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export class TransformUiPlugin { diff --git a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts index 88b5396c7b110..eb51c04e0bca7 100644 --- a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts +++ b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts @@ -8,16 +8,21 @@ import { ElasticsearchClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import type { Transform as EsTransform } from '@elastic/elasticsearch/api/types'; +import { keyBy } from 'lodash'; import { TransformHealthRuleParams } from './schema'; import { ALL_TRANSFORMS_SELECTION, TRANSFORM_HEALTH_CHECK_NAMES, + TRANSFORM_RULE_TYPE, } from '../../../../common/constants'; import { getResultTestConfig } from '../../../../common/utils/alerts'; import { NotStartedTransformResponse, TransformHealthAlertContext, } from './register_transform_health_rule_type'; +import type { RulesClient } from '../../../../../alerting/server'; +import type { TransformHealthAlertRule } from '../../../../common/types/alerting'; +import { isContinuousTransform } from '../../../../common/types/transform'; interface TestResult { name: string; @@ -27,37 +32,48 @@ interface TestResult { // @ts-ignore FIXME update types in the elasticsearch client type Transform = EsTransform & { id: string; description?: string; sync: object }; -export function transformHealthServiceProvider(esClient: ElasticsearchClient) { +type TransformWithAlertingRules = Transform & { alerting_rules: TransformHealthAlertRule[] }; + +export function transformHealthServiceProvider( + esClient: ElasticsearchClient, + rulesClient?: RulesClient +) { const transformsDict = new Map(); /** * Resolves result transform selection. * @param includeTransforms * @param excludeTransforms + * @param skipIDsCheck */ const getResultsTransformIds = async ( includeTransforms: string[], - excludeTransforms: string[] | null + excludeTransforms: string[] | null, + skipIDsCheck = false ): Promise => { const includeAll = includeTransforms.some((id) => id === ALL_TRANSFORMS_SELECTION); - // Fetch transforms to make sure assigned transforms exists. - const transformsResponse = ( - await esClient.transform.getTransform({ - ...(includeAll ? {} : { transform_id: includeTransforms.join(',') }), - allow_no_match: true, - size: 1000, - }) - ).body.transforms as Transform[]; - let resultTransformIds: string[] = []; - transformsResponse.forEach((t) => { - transformsDict.set(t.id, t); - if (t.sync) { - resultTransformIds.push(t.id); - } - }); + if (skipIDsCheck) { + resultTransformIds = includeTransforms; + } else { + // Fetch transforms to make sure assigned transforms exists. + const transformsResponse = ( + await esClient.transform.getTransform({ + ...(includeAll ? {} : { transform_id: includeTransforms.join(',') }), + allow_no_match: true, + size: 1000, + }) + ).body.transforms as Transform[]; + + transformsResponse.forEach((t) => { + transformsDict.set(t.id, t); + if (t.sync) { + resultTransformIds.push(t.id); + } + }); + } if (excludeTransforms && excludeTransforms.length > 0) { const excludeIdsSet = new Set(excludeTransforms); @@ -129,6 +145,53 @@ export function transformHealthServiceProvider(esClient: ElasticsearchClient) { return result; }, + + /** + * Updates transform list with associated alerting rules. + */ + async populateTransformsWithAssignedRules( + transforms: Transform[] + ): Promise { + const newList = transforms.filter(isContinuousTransform) as TransformWithAlertingRules[]; + + if (!rulesClient) { + throw new Error('Rules client is missing'); + } + + const transformMap = keyBy(newList, 'id'); + + const transformAlertingRules = await rulesClient.find({ + options: { + perPage: 1000, + filter: `alert.attributes.alertTypeId:${TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH}`, + }, + }); + + for (const ruleInstance of transformAlertingRules.data) { + // Retrieve result transform IDs + const resultTransformIds: string[] = await getResultsTransformIds( + ruleInstance.params.includeTransforms.includes(ALL_TRANSFORMS_SELECTION) + ? Object.keys(transformMap) + : ruleInstance.params.includeTransforms, + ruleInstance.params.excludeTransforms, + true + ); + + resultTransformIds.forEach((transformId) => { + const transformRef = transformMap[transformId] as TransformWithAlertingRules; + + if (transformRef) { + if (Array.isArray(transformRef.alerting_rules)) { + transformRef.alerting_rules.push(ruleInstance); + } else { + transformRef.alerting_rules = [ruleInstance]; + } + } + }); + } + + return newList; + }, }; } diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 76aac9686c37e..4a657ae615d94 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -63,6 +63,7 @@ import { registerTransformNodesRoutes } from './transforms_nodes'; import { IIndexPattern } from '../../../../../../src/plugins/data/common'; import { isLatestTransform } from '../../../common/types/transform'; import { isKeywordDuplicate } from '../../../common/utils/field_utils'; +import { transformHealthServiceProvider } from '../../lib/alerting/transform_health_rule_type/transform_health_service'; enum TRANSFORM_ACTIONS { STOP = 'stop', @@ -90,6 +91,17 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { size: 1000, ...req.params, }); + + if (ctx.alerting) { + const transformHealthService = transformHealthServiceProvider( + ctx.core.elasticsearch.client.asCurrentUser, + ctx.alerting.getRulesClient() + ); + + // @ts-ignore + await transformHealthService.populateTransformsWithAssignedRules(body.transforms); + } + return res.ok({ body }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); diff --git a/x-pack/plugins/transform/server/services/license.ts b/x-pack/plugins/transform/server/services/license.ts index 978912ce08baf..ce28e0365bb21 100644 --- a/x-pack/plugins/transform/server/services/license.ts +++ b/x-pack/plugins/transform/server/services/license.ts @@ -15,6 +15,7 @@ import { } from 'kibana/server'; import { LicensingPluginSetup, LicenseType } from '../../../licensing/server'; +import type { AlertingApiRequestHandlerContext } from '../../../alerting/server'; export interface LicenseStatus { isValid: boolean; @@ -28,6 +29,10 @@ interface SetupSettings { defaultErrorMessage: string; } +type TransformRequestHandlerContext = RequestHandlerContext & { + alerting?: AlertingApiRequestHandlerContext; +}; + export class License { private licenseStatus: LicenseStatus = { isValid: false, @@ -64,7 +69,9 @@ export class License { }); } - guardApiRoute(handler: RequestHandler) { + guardApiRoute( + handler: RequestHandler + ) { const license = this; return function licenseCheck( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 852b01977b78b..c54512203677d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2424,7 +2424,6 @@ "discover.docViews.table.toggleFieldDetails": "フィールド詳細を切り替える", "discover.docViews.table.unableToFilterForPresenceOfMetaFieldsTooltip": "メタフィールドの有無でフィルタリングできません", "discover.docViews.table.unableToFilterForPresenceOfScriptedFieldsTooltip": "スクリプトフィールドの有無でフィルタリングできません", - "discover.docViews.table.unindexedFieldsCanNotBeSearchedTooltip": "インデックスされていないフィールドは検索できません", "discover.embeddable.inspectorRequestDataTitle": "データ", "discover.embeddable.inspectorRequestDescription": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。", "discover.embeddable.search.displayName": "検索", @@ -2528,7 +2527,6 @@ "discover.localMenu.openSavedSearchDescription": "保存された検索を開きます", "discover.localMenu.openTitle": "開く", "discover.localMenu.optionsDescription": "オプション", - "discover.localMenu.saveSaveSearchDescription": "ビジュアライゼーションとダッシュボードで使用できるように Discover の検索を保存します", "discover.localMenu.saveSaveSearchObjectType": "検索", "discover.localMenu.saveSearchDescription": "検索を保存します", "discover.localMenu.saveTitle": "保存", @@ -2916,7 +2914,6 @@ "home.addData.sampleDataButtonLabel": "サンプルデータを試す", "home.addData.sectionTitle": "データを追加して開始する", "home.addData.text": "データの操作を開始するには、多数の取り込みオプションのいずれかを使用します。アプリまたはサービスからデータを収集するか、ファイルをアップロードします。独自のデータを使用する準備ができていない場合は、サンプルデータセットを追加してください。", - "home.breadcrumbs.addDataTitle": "データの追加", "home.breadcrumbs.homeTitle": "ホーム", "home.dataManagementDisableCollection": " 収集を停止するには、", "home.dataManagementDisableCollectionLink": "ここで使用状況データを無効にします。", @@ -3725,7 +3722,6 @@ "indexPatternEditor.aliasLabel": "エイリアス", "indexPatternEditor.createIndex.noMatch": "名前は1つ以上のデータストリーム、インデックス、またはインデックスエイリアスと一致する必要があります。", "indexPatternEditor.createIndexPattern.emptyState.checkDataButton": "新規データを確認", - "indexPatternEditor.createIndexPattern.emptyState.createAnyway": "一部のインデックスは表示されない場合があります。{link}してください。", "indexPatternEditor.createIndexPattern.emptyState.createAnywayLink": "インデックスパターンを作成します", "indexPatternEditor.createIndexPattern.emptyState.haveData": "すでにデータがある場合", "indexPatternEditor.createIndexPattern.emptyState.integrationCardDescription": "さまざまなソースからデータを追加します。", @@ -4508,8 +4504,6 @@ "telemetry.optInNoticeSeenErrorToastText": "通知の消去中にエラーが発生しました", "telemetry.optInSuccessOff": "使用状況データ収集がオフです。", "telemetry.optInSuccessOn": "使用状況データ収集がオンです。", - "telemetry.provideUsageStatisticsAriaName": "使用統計を提供", - "telemetry.provideUsageStatisticsTitle": "使用統計を提供", "telemetry.readOurUsageDataPrivacyStatementLinkText": "プライバシーポリシー", "telemetry.securityData": "Endpoint Security データ", "telemetry.telemetryBannerDescription": "Elastic Stackの改善にご協力ください使用状況データの収集は現在無効です。使用状況データの収集を有効にすると、製品とサービスを管理して改善することができます。詳細については、{privacyStatementLink}をご覧ください。", @@ -6793,7 +6787,6 @@ "xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "サンプルドキュメントを表示", "xpack.apm.transactionBreakdown.chartTitle": "スパンタイプ別時間", "xpack.apm.transactionDetails.clearSelectionAriaLabel": "選択した項目をクリア", - "xpack.apm.transactionDetails.distribution.errorTitle": "分布の取得中にエラーが発生しました", "xpack.apm.transactionDetails.distribution.panelTitle": "レイテンシ分布", "xpack.apm.transactionDetails.emptySelectionText": "クリックおよびドラッグして範囲を選択", "xpack.apm.transactionDetails.noTraceParentButtonTooltip": "トレースの親が見つかりませんでした", @@ -13449,15 +13442,12 @@ "xpack.infra.metrics.alerting.threshold.errorAlertReason": "{metric}のデータのクエリを試行しているときに、Elasticsearchが失敗しました", "xpack.infra.metrics.alerting.threshold.errorState": "エラー", "xpack.infra.metrics.alerting.threshold.fired": "アラート", - "xpack.infra.metrics.alerting.threshold.firedAlertReason": "{metric}は{comparator} {threshold}のしきい値です(現在の値は{currentValue})", "xpack.infra.metrics.alerting.threshold.gtComparator": "より大きい", "xpack.infra.metrics.alerting.threshold.ltComparator": "より小さい", - "xpack.infra.metrics.alerting.threshold.noDataAlertReason": "{metric}は過去{interval}にデータを報告していません", "xpack.infra.metrics.alerting.threshold.noDataFormattedValue": "[データなし]", "xpack.infra.metrics.alerting.threshold.noDataState": "データなし", "xpack.infra.metrics.alerting.threshold.okState": "OK [回復済み]", "xpack.infra.metrics.alerting.threshold.outsideRangeComparator": "の間にない", - "xpack.infra.metrics.alerting.threshold.recoveredAlertReason": "{metric}は{comparator} {threshold}のしきい値です(現在の値は{currentValue})", "xpack.infra.metrics.alerting.threshold.thresholdRange": "{a}と{b}", "xpack.infra.metrics.alerting.threshold.warning": "警告", "xpack.infra.metrics.alerting.threshold.warningState": "警告", @@ -13703,20 +13693,9 @@ "xpack.infra.sourceConfiguration.anomalyThresholdLabel": "最低重要度スコア", "xpack.infra.sourceConfiguration.anomalyThresholdTitle": "異常重要度しきい値", "xpack.infra.sourceConfiguration.applySettingsButtonLabel": "適用", - "xpack.infra.sourceConfiguration.containerFieldDescription": "Docker コンテナーの識別に使用されるフィールドです", - "xpack.infra.sourceConfiguration.containerFieldLabel": "コンテナー ID", - "xpack.infra.sourceConfiguration.containerFieldRecommendedValue": "推奨値は {defaultValue} です", - "xpack.infra.sourceConfiguration.deprecationMessage": "これらのフィールドの構成は廃止予定です。8.0.0で削除されます。このアプリケーションは{ecsLink}で動作するように設計されています。{documentationLink}を使用するには、インデックスを調整してください。", - "xpack.infra.sourceConfiguration.deprecationNotice": "廃止通知", "xpack.infra.sourceConfiguration.discardSettingsButtonLabel": "破棄", - "xpack.infra.sourceConfiguration.documentedFields": "文書化されたフィールド", "xpack.infra.sourceConfiguration.fieldEmptyErrorMessage": "このフィールドは未入力のままにできません。", "xpack.infra.sourceConfiguration.fieldLogColumnTitle": "フィールド", - "xpack.infra.sourceConfiguration.fieldsSectionTitle": "フィールド", - "xpack.infra.sourceConfiguration.hostFieldDescription": "推奨値は {defaultValue} です", - "xpack.infra.sourceConfiguration.hostFieldLabel": "ホスト名", - "xpack.infra.sourceConfiguration.hostNameFieldDescription": "ホストの識別に使用されるフィールドです", - "xpack.infra.sourceConfiguration.hostNameFieldLabel": "ホスト名", "xpack.infra.sourceConfiguration.indicesSectionTitle": "インデックス", "xpack.infra.sourceConfiguration.logColumnsSectionTitle": "ログ列", "xpack.infra.sourceConfiguration.logIndicesDescription": "ログデータを含む一致するインデックスのインデックスパターンです", @@ -13734,17 +13713,8 @@ "xpack.infra.sourceConfiguration.nameSectionTitle": "名前", "xpack.infra.sourceConfiguration.noLogColumnsDescription": "上のボタンでこのリストに列を追加します。", "xpack.infra.sourceConfiguration.noLogColumnsTitle": "列がありません", - "xpack.infra.sourceConfiguration.podFieldDescription": "Kubernetes ポッドの識別に使用されるフィールドです", - "xpack.infra.sourceConfiguration.podFieldLabel": "ポッド ID", - "xpack.infra.sourceConfiguration.podFieldRecommendedValue": "推奨値は {defaultValue} です", "xpack.infra.sourceConfiguration.removeLogColumnButtonLabel": "{columnDescription} 列を削除", "xpack.infra.sourceConfiguration.systemColumnBadgeLabel": "システム", - "xpack.infra.sourceConfiguration.tiebreakerFieldDescription": "同じタイムスタンプの 2 つのエントリーを識別するのに使用されるフィールドです", - "xpack.infra.sourceConfiguration.tiebreakerFieldLabel": "タイブレーカー", - "xpack.infra.sourceConfiguration.tiebreakerFieldRecommendedValue": "推奨値は {defaultValue} です", - "xpack.infra.sourceConfiguration.timestampFieldDescription": "ログエントリーの並べ替えに使用されるタイムスタンプです", - "xpack.infra.sourceConfiguration.timestampFieldLabel": "タイムスタンプ", - "xpack.infra.sourceConfiguration.timestampFieldRecommendedValue": "推奨値は {defaultValue} です", "xpack.infra.sourceConfiguration.timestampLogColumnDescription": "このシステムフィールドは、{timestampSetting} フィールド設定から判断されたログエントリーの時刻を表示します。", "xpack.infra.sourceConfiguration.unsavedFormPrompt": "終了してよろしいですか?変更内容は失われます", "xpack.infra.sourceErrorPage.failedToLoadDataSourcesMessage": "データソースの読み込みに失敗しました。", @@ -24988,15 +24958,8 @@ "xpack.triggersActionsUI.components.healthCheck.alertsErrorAction": "方法を確認してください。", "xpack.triggersActionsUI.components.healthCheck.alertsErrorTitle": "アラートとアクションを有効にする必要があります", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorAction": "方法を確認してください。", - "xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey": " kibana.ymlファイルで、暗号化された保存されたプラグインが有効になっていることを確認してください。", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey": "ルールを作成するには、値を設定します ", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle": "暗号化された保存されたオブジェクトがありません", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError": "KibanaとElasticsearchの間でトランスポートレイヤーセキュリティを有効にし、kibana.ymlファイルで暗号化鍵を構成する必要があります。", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction": "方法を確認してください。", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle": "追加の設定が必要です", - "xpack.triggersActionsUI.components.healthCheck.tlsError": "アラートはAPIキーに依存し、キーを使用するにはElasticsearchとKibanaの間にTLSが必要です。", - "xpack.triggersActionsUI.components.healthCheck.tlsErrorAction": "TLSを有効にする方法をご覧ください。", - "xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle": "トランスポートレイヤーセキュリティとAPIキーを有効にする必要があります", "xpack.triggersActionsUI.connectors.breadcrumbTitle": "コネクター", "xpack.triggersActionsUI.data.coreQueryParams.aggTypeRequiredErrorMessage": "[aggType]が「{aggType}」のときには[aggField]に値が必要です", "xpack.triggersActionsUI.data.coreQueryParams.dateStartGTdateEndErrorMessage": "[dateStart]が[dateEnd]よりも大です", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9d88c757f1e58..fbc65161a47f8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2449,7 +2449,6 @@ "discover.docViews.table.toggleFieldDetails": "切换字段详细信息", "discover.docViews.table.unableToFilterForPresenceOfMetaFieldsTooltip": "无法筛选元数据字段是否存在", "discover.docViews.table.unableToFilterForPresenceOfScriptedFieldsTooltip": "无法筛选脚本字段是否存在", - "discover.docViews.table.unindexedFieldsCanNotBeSearchedTooltip": "无法搜索未索引字段", "discover.embeddable.inspectorRequestDataTitle": "数据", "discover.embeddable.inspectorRequestDescription": "此请求将查询 Elasticsearch 以获取搜索的数据。", "discover.embeddable.search.displayName": "搜索", @@ -2554,7 +2553,6 @@ "discover.localMenu.openSavedSearchDescription": "打开已保存搜索", "discover.localMenu.openTitle": "打开", "discover.localMenu.optionsDescription": "选项", - "discover.localMenu.saveSaveSearchDescription": "保存您的 Discover 搜索,以便可以在可视化和仪表板中使用该搜索", "discover.localMenu.saveSaveSearchObjectType": "搜索", "discover.localMenu.saveSearchDescription": "保存搜索", "discover.localMenu.saveTitle": "保存", @@ -2945,7 +2943,6 @@ "home.addData.sampleDataButtonLabel": "试用样例数据", "home.addData.sectionTitle": "首先添加您的数据", "home.addData.text": "要开始使用您的数据,请使用我们众多采集选项中的一个选项。从应用或服务收集数据,或上传文件。如果未准备好使用自己的数据,请添加示例数据集。", - "home.breadcrumbs.addDataTitle": "添加数据", "home.breadcrumbs.homeTitle": "主页", "home.dataManagementDisableCollection": " 要停止收集,", "home.dataManagementDisableCollectionLink": "请在此禁用使用情况数据。", @@ -3758,7 +3755,6 @@ "indexPatternEditor.aliasLabel": "别名", "indexPatternEditor.createIndex.noMatch": "名称必须匹配一个或多个数据流、索引或索引别名。", "indexPatternEditor.createIndexPattern.emptyState.checkDataButton": "检查新数据", - "indexPatternEditor.createIndexPattern.emptyState.createAnyway": "部分索引可能已隐藏。仍然尝试{link}。", "indexPatternEditor.createIndexPattern.emptyState.createAnywayLink": "创建索引模式", "indexPatternEditor.createIndexPattern.emptyState.haveData": "假设您已有数据?", "indexPatternEditor.createIndexPattern.emptyState.integrationCardDescription": "从各种源添加数据。", @@ -4553,8 +4549,6 @@ "telemetry.optInNoticeSeenErrorToastText": "关闭声明时发生错误", "telemetry.optInSuccessOff": "使用情况数据收集已关闭。", "telemetry.optInSuccessOn": "使用情况数据收集已打开。", - "telemetry.provideUsageStatisticsAriaName": "提供使用情况统计", - "telemetry.provideUsageStatisticsTitle": "提供使用情况统计", "telemetry.readOurUsageDataPrivacyStatementLinkText": "隐私声明", "telemetry.securityData": "终端安全数据", "telemetry.telemetryBannerDescription": "想帮助我们改进 Elastic Stack?数据使用情况收集当前已禁用。启用使用情况数据收集可帮助我们管理并改善产品和服务。有关更多详情,请参阅我们的{privacyStatementLink}。", @@ -6849,7 +6843,6 @@ "xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "查看样例文档", "xpack.apm.transactionBreakdown.chartTitle": "跨度类型花费的时间", "xpack.apm.transactionDetails.clearSelectionAriaLabel": "清除所选内容", - "xpack.apm.transactionDetails.distribution.errorTitle": "获取分布时出错", "xpack.apm.transactionDetails.distribution.panelTitle": "延迟分布", "xpack.apm.transactionDetails.emptySelectionText": "单击并拖动以选择范围", "xpack.apm.transactionDetails.errorCount": "{errorCount, number} 个 {errorCount, plural, other {错误}}", @@ -13637,15 +13630,12 @@ "xpack.infra.metrics.alerting.threshold.errorAlertReason": "Elasticsearch 尝试查询 {metric} 的数据时出现故障", "xpack.infra.metrics.alerting.threshold.errorState": "错误", "xpack.infra.metrics.alerting.threshold.fired": "告警", - "xpack.infra.metrics.alerting.threshold.firedAlertReason": "{metric} {comparator}阈值 {threshold}(当前值为 {currentValue})", "xpack.infra.metrics.alerting.threshold.gtComparator": "大于", "xpack.infra.metrics.alerting.threshold.ltComparator": "小于", - "xpack.infra.metrics.alerting.threshold.noDataAlertReason": "{metric} 在过去 {interval}中未报告数据", "xpack.infra.metrics.alerting.threshold.noDataFormattedValue": "[无数据]", "xpack.infra.metrics.alerting.threshold.noDataState": "无数据", "xpack.infra.metrics.alerting.threshold.okState": "正常 [已恢复]", "xpack.infra.metrics.alerting.threshold.outsideRangeComparator": "不介于", - "xpack.infra.metrics.alerting.threshold.recoveredAlertReason": "{metric} 现在{comparator}阈值 {threshold}(当前值为 {currentValue})", "xpack.infra.metrics.alerting.threshold.thresholdRange": "{a} 和 {b}", "xpack.infra.metrics.alerting.threshold.warning": "警告", "xpack.infra.metrics.alerting.threshold.warningState": "警告", @@ -13891,20 +13881,9 @@ "xpack.infra.sourceConfiguration.anomalyThresholdLabel": "最低严重性分数", "xpack.infra.sourceConfiguration.anomalyThresholdTitle": "异常严重性阈值", "xpack.infra.sourceConfiguration.applySettingsButtonLabel": "应用", - "xpack.infra.sourceConfiguration.containerFieldDescription": "用于标识 Docker 容器的字段", - "xpack.infra.sourceConfiguration.containerFieldLabel": "容器 ID", - "xpack.infra.sourceConfiguration.containerFieldRecommendedValue": "推荐值为 {defaultValue}", - "xpack.infra.sourceConfiguration.deprecationMessage": "有关这些字段的配置已过时,将在 8.0.0 中移除。此应用程序专用于 {ecsLink},您应调整索引以使用{documentationLink}。", - "xpack.infra.sourceConfiguration.deprecationNotice": "过时通知", "xpack.infra.sourceConfiguration.discardSettingsButtonLabel": "丢弃", - "xpack.infra.sourceConfiguration.documentedFields": "已记录字段", "xpack.infra.sourceConfiguration.fieldEmptyErrorMessage": "字段不得为空。", "xpack.infra.sourceConfiguration.fieldLogColumnTitle": "字段", - "xpack.infra.sourceConfiguration.fieldsSectionTitle": "字段", - "xpack.infra.sourceConfiguration.hostFieldDescription": "推荐值为 {defaultValue}", - "xpack.infra.sourceConfiguration.hostFieldLabel": "主机名", - "xpack.infra.sourceConfiguration.hostNameFieldDescription": "用于标识主机的字段", - "xpack.infra.sourceConfiguration.hostNameFieldLabel": "主机名", "xpack.infra.sourceConfiguration.indicesSectionTitle": "索引", "xpack.infra.sourceConfiguration.logColumnsSectionTitle": "日志列", "xpack.infra.sourceConfiguration.logIndicesDescription": "用于匹配包含日志数据的索引的索引模式", @@ -13922,17 +13901,8 @@ "xpack.infra.sourceConfiguration.nameSectionTitle": "名称", "xpack.infra.sourceConfiguration.noLogColumnsDescription": "使用上面的按钮将列添加到此列表。", "xpack.infra.sourceConfiguration.noLogColumnsTitle": "无列", - "xpack.infra.sourceConfiguration.podFieldDescription": "用于标识 Kubernetes Pod 的字段", - "xpack.infra.sourceConfiguration.podFieldLabel": "Pod ID", - "xpack.infra.sourceConfiguration.podFieldRecommendedValue": "推荐值为 {defaultValue}", "xpack.infra.sourceConfiguration.removeLogColumnButtonLabel": "删除“{columnDescription}”列", "xpack.infra.sourceConfiguration.systemColumnBadgeLabel": "系统", - "xpack.infra.sourceConfiguration.tiebreakerFieldDescription": "用于时间戳相同的两个条目间决胜的字段", - "xpack.infra.sourceConfiguration.tiebreakerFieldLabel": "决胜属性", - "xpack.infra.sourceConfiguration.tiebreakerFieldRecommendedValue": "推荐值为 {defaultValue}", - "xpack.infra.sourceConfiguration.timestampFieldDescription": "用于排序日志条目的时间戳", - "xpack.infra.sourceConfiguration.timestampFieldLabel": "时间戳", - "xpack.infra.sourceConfiguration.timestampFieldRecommendedValue": "推荐值为 {defaultValue}", "xpack.infra.sourceConfiguration.timestampLogColumnDescription": "此系统字段显示 {timestampSetting} 字段设置所确定的日志条目时间。", "xpack.infra.sourceConfiguration.unsavedFormPrompt": "是否确定要离开?更改将丢失", "xpack.infra.sourceErrorPage.failedToLoadDataSourcesMessage": "无法加载数据源。", @@ -25414,15 +25384,8 @@ "xpack.triggersActionsUI.components.healthCheck.alertsErrorAction": "了解操作方法。", "xpack.triggersActionsUI.components.healthCheck.alertsErrorTitle": "必须启用“告警和操作”", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorAction": "了解操作方法。", - "xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey": " 设置值,并确保启用加密已保存对象插件。", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey": "要创建规则,请在 kibana.yml 文件中为: ", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle": "加密已保存对象不可用", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError": "必须在 Kibana 和 Elasticsearch 之间启用传输层安全并在 kibana.yml 文件中配置加密密钥。", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction": "了解操作方法。", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle": "需要其他设置", - "xpack.triggersActionsUI.components.healthCheck.tlsError": "Alerting 功能依赖于 API 密钥,这需要在 Elasticsearch 与 Kibana 之间启用 TLS。", - "xpack.triggersActionsUI.components.healthCheck.tlsErrorAction": "了解如何启用 TLS。", - "xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle": "必须启用传输层安全和 API 密钥", "xpack.triggersActionsUI.connectors.breadcrumbTitle": "连接器", "xpack.triggersActionsUI.data.coreQueryParams.aggTypeRequiredErrorMessage": "[aggField]:当 [aggType] 为“{aggType}”时必须有值", "xpack.triggersActionsUI.data.coreQueryParams.dateStartGTdateEndErrorMessage": "[dateStart]:晚于 [dateEnd]", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index 4266822bda1fc..d9bad9677c6b8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -14,10 +14,16 @@ import { getSwimlaneActionType } from './swimlane'; import { getWebhookActionType } from './webhook'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; -import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; +import { + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getServiceNowITOMActionType, +} from './servicenow'; import { getJiraActionType } from './jira'; import { getResilientActionType } from './resilient'; import { getTeamsActionType } from './teams'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ENABLE_ITOM } from '../../../../../actions/server/constants/connectors'; export function registerBuiltInActionTypes({ actionTypeRegistry, @@ -36,4 +42,9 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getJiraActionType()); actionTypeRegistry.register(getResilientActionType()); actionTypeRegistry.register(getTeamsActionType()); + + // TODO: Remove when ITOM is ready + if (ENABLE_ITOM) { + actionTypeRegistry.register(getServiceNowITOMActionType()); + } } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts index e37d8dd3b4147..e40db85bcb12d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts @@ -35,6 +35,10 @@ describe('helpers', () => { expect(isFieldInvalid(undefined, ['required'])).toBeFalsy(); }); + test('should return if false the field is null', async () => { + expect(isFieldInvalid(null, ['required'])).toBeFalsy(); + }); + test('should return if false the error is not defined', async () => { // @ts-expect-error expect(isFieldInvalid('description', undefined)).toBeFalsy(); 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 index 0134133645bb3..e6acb2e0976a8 100644 --- 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 @@ -23,9 +23,9 @@ export const isRESTApiError = (res: AppInfo | RESTApiError): res is RESTApiError (res as RESTApiError).error != null || (res as RESTApiError).status === 'failure'; export const isFieldInvalid = ( - field: string | undefined, + field: string | undefined | null, error: string | IErrorObject | string[] -): boolean => error !== undefined && error.length > 0 && field !== undefined; +): boolean => error !== undefined && error.length > 0 && field != null; // TODO: Remove when the applications are certified export const isLegacyConnector = (connector: ServiceNowActionConnector) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts index b5b006b764e41..c313fd5d0edd6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/index.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow'; +export { + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getServiceNowITOMActionType, +} from './servicenow'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index b40db9c2dabda..eb3e1c01887c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -12,6 +12,7 @@ import { ServiceNowActionConnector } from './types'; const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; +const SERVICENOW_ITOM_ACTION_TYPE_ID = '.servicenow-itom'; let actionTypeRegistry: TypeRegistry; beforeAll(() => { @@ -20,7 +21,11 @@ beforeAll(() => { }); describe('actionTypeRegistry.get() works', () => { - [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { + [ + SERVICENOW_ITSM_ACTION_TYPE_ID, + SERVICENOW_SIR_ACTION_TYPE_ID, + SERVICENOW_ITOM_ACTION_TYPE_ID, + ].forEach((id) => { test(`${id}: action type static data is as expected`, () => { const actionTypeModel = actionTypeRegistry.get(id); expect(actionTypeModel.id).toEqual(id); @@ -29,7 +34,11 @@ describe('actionTypeRegistry.get() works', () => { }); describe('servicenow connector validation', () => { - [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { + [ + SERVICENOW_ITSM_ACTION_TYPE_ID, + SERVICENOW_SIR_ACTION_TYPE_ID, + SERVICENOW_ITOM_ACTION_TYPE_ID, + ].forEach((id) => { test(`${id}: connector validation succeeds when connector config is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = { @@ -106,7 +115,7 @@ describe('servicenow action params validation', () => { }); }); - test(`${id}: params validation fails when body is not valid`, async () => { + test(`${id}: params validation fails when short_description is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: '' }, comments: [] }, @@ -119,4 +128,22 @@ describe('servicenow action params validation', () => { }); }); }); + + test(`${SERVICENOW_ITOM_ACTION_TYPE_ID}: action params validation succeeds when action params is valid`, async () => { + const actionTypeModel = actionTypeRegistry.get(SERVICENOW_ITOM_ACTION_TYPE_ID); + const actionParams = { subActionParams: { severity: 'Critical' } }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { ['severity']: [] }, + }); + }); + + test(`${SERVICENOW_ITOM_ACTION_TYPE_ID}: params validation fails when severity is not valid`, async () => { + const actionTypeModel = actionTypeRegistry.get(SERVICENOW_ITOM_ACTION_TYPE_ID); + const actionParams = { subActionParams: { severity: null } }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { ['severity']: ['Severity is required.'] }, + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index bb4a645f10bbc..6b6d536ff303b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -18,6 +18,7 @@ import { ServiceNowSecrets, ServiceNowITSMActionParams, ServiceNowSIRActionParams, + ServiceNowITOMActionParams, } from './types'; import { isValidUrl } from '../../../lib/value_validators'; @@ -90,6 +91,20 @@ export const SERVICENOW_SIR_TITLE = i18n.translate( } ); +export const SERVICENOW_ITOM_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITOM.actionTypeTitle', + { + defaultMessage: 'ServiceNow ITOM', + } +); + +export const SERVICENOW_ITOM_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITOM.selectMessageText', + { + defaultMessage: 'Create an event in ServiceNow ITOM.', + } +); + export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowConfig, ServiceNowSecrets, @@ -161,3 +176,34 @@ export function getServiceNowSIRActionType(): ActionTypeModel< actionParamsFields: lazy(() => import('./servicenow_sir_params')), }; } + +export function getServiceNowITOMActionType(): ActionTypeModel< + ServiceNowConfig, + ServiceNowSecrets, + ServiceNowITOMActionParams +> { + return { + id: '.servicenow-itom', + iconClass: lazy(() => import('./logo')), + selectMessage: SERVICENOW_ITOM_DESC, + actionTypeTitle: SERVICENOW_ITOM_TITLE, + validateConnector, + actionConnectorFields: lazy(() => import('./servicenow_connectors_no_app')), + validateParams: async ( + actionParams: ServiceNowITOMActionParams + ): Promise> => { + const translations = await import('./translations'); + const errors = { + severity: new Array(), + }; + const validationResult = { errors }; + + if (actionParams?.subActionParams?.severity == null) { + errors.severity.push(translations.SEVERITY_REQUIRED); + } + + return validationResult; + }, + actionParamsFields: lazy(() => import('./servicenow_itom_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_no_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_no_app.tsx new file mode 100644 index 0000000000000..f49c2d34d3a8d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_no_app.tsx @@ -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 React from 'react'; + +import { ActionConnectorFieldsProps } from '../../../../types'; + +import { ServiceNowActionConnector } from './types'; +import { Credentials } from './credentials'; + +const ServiceNowConnectorFieldsNoApp: React.FC< + ActionConnectorFieldsProps +> = ({ action, editActionSecrets, editActionConfig, errors, readOnly }) => { + return ( + <> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowConnectorFieldsNoApp as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.test.tsx new file mode 100644 index 0000000000000..ef934d4ebacd7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.test.tsx @@ -0,0 +1,179 @@ +/* + * 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 { mount } from 'enzyme'; + +import { ActionConnector } from '../../../../types'; +import { useChoices } from './use_choices'; +import ServiceNowITOMParamsFields from './servicenow_itom_params'; + +jest.mock('./use_choices'); +jest.mock('../../../../common/lib/kibana'); + +const useChoicesMock = useChoices as jest.Mock; + +const actionParams = { + subAction: 'addEvent', + subActionParams: { + source: 'A source', + event_class: 'An event class', + resource: 'C:', + node: 'node.example.com', + metric_name: 'Percentage Logical Disk Free Space', + type: 'Disk space', + severity: '4', + description: 'desc', + additional_info: '{"alert": "test"}', + message_key: 'a key', + time_of_event: '2021-10-13T10:51:44.981Z', + }, +}; + +const connector: ActionConnector = { + secrets: {}, + config: {}, + id: 'test', + actionTypeId: '.test', + name: 'Test', + isPreconfigured: false, +}; + +const editAction = jest.fn(); +const defaultProps = { + actionConnector: connector, + actionParams, + errors: { ['subActionParams.incident.short_description']: [] }, + editAction, + index: 0, + messageVariables: [], +}; + +const choicesResponse = { + isLoading: false, + choices: { + severity: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element: 'severity', + }, + { + dependent_value: '', + label: '2 - Major', + value: '2', + element: 'severity', + }, + ], + }, +}; + +describe('ServiceNowITOMParamsFields renders', () => { + beforeEach(() => { + jest.clearAllMocks(); + useChoicesMock.mockImplementation((args) => { + return choicesResponse; + }); + }); + + test('all params fields is rendered', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="sourceInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="nodeInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="typeInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="resourceInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="metric_nameInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="event_classInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="message_keyInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); + }); + + test('If severity has errors, form row is invalid', () => { + const newProps = { + ...defaultProps, + errors: { severity: ['error'] }, + }; + const wrapper = mount(); + const severity = wrapper.find('[data-test-subj="severitySelect"]').first(); + expect(severity.prop('isInvalid')).toBeTruthy(); + }); + + test('When subActionParams is undefined, set to default', () => { + const { subActionParams, ...newParams } = actionParams; + + const newProps = { + ...defaultProps, + actionParams: newParams, + }; + + mount(); + expect(editAction.mock.calls[0][1]).toEqual({ + message_key: '{{rule.id}}:{{alert.id}}', + additional_info: + '{"alert":{"id":"{{alert.id}}","actionGroup":"{{alert.actionGroup}}","actionSubgroup":"{{alert.actionSubgroup}}","actionGroupName":"{{alert.actionGroupName}}"},"rule":{"id":"{{rule.id}}","name":"{{rule.name}}","type":"{{rule.type}}"},"date":"{{date}}"}', + }); + }); + + test('When subAction is undefined, set to default', () => { + const { subAction, ...newParams } = actionParams; + + const newProps = { + ...defaultProps, + actionParams: newParams, + }; + mount(); + expect(editAction.mock.calls[0][1]).toEqual('addEvent'); + }); + + test('Resets fields when connector changes', () => { + const wrapper = mount(); + expect(editAction.mock.calls.length).toEqual(0); + wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); + expect(editAction.mock.calls.length).toEqual(1); + expect(editAction.mock.calls[0][1]).toEqual({ + message_key: '{{rule.id}}:{{alert.id}}', + additional_info: + '{"alert":{"id":"{{alert.id}}","actionGroup":"{{alert.actionGroup}}","actionSubgroup":"{{alert.actionSubgroup}}","actionGroupName":"{{alert.actionGroupName}}"},"rule":{"id":"{{rule.id}}","name":"{{rule.name}}","type":"{{rule.type}}"},"date":"{{date}}"}', + }); + }); + + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - Major' }, + ]); + }); + + describe('UI updates', () => { + const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; + const simpleFields = [ + { dataTestSubj: 'input[data-test-subj="sourceInput"]', key: 'source' }, + { dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' }, + { dataTestSubj: '[data-test-subj="nodeInput"]', key: 'node' }, + { dataTestSubj: '[data-test-subj="typeInput"]', key: 'type' }, + { dataTestSubj: '[data-test-subj="resourceInput"]', key: 'resource' }, + { dataTestSubj: '[data-test-subj="metric_nameInput"]', key: 'metric_name' }, + { dataTestSubj: '[data-test-subj="event_classInput"]', key: 'event_class' }, + { dataTestSubj: '[data-test-subj="message_keyInput"]', key: 'message_key' }, + { dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' }, + ]; + + simpleFields.forEach((field) => + test(`${field.key} update triggers editAction :D`, () => { + const wrapper = mount(); + const theField = wrapper.find(field.dataTestSubj).first(); + theField.prop('onChange')!(changeEvent); + expect(editAction.mock.calls[0][1][field.key]).toEqual(changeEvent.target.value); + }) + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.tsx new file mode 100644 index 0000000000000..8ad32fc0bc86b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itom_params.tsx @@ -0,0 +1,160 @@ +/* + * 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, useEffect, useRef, useMemo } from 'react'; +import { EuiFormRow, EuiSpacer, EuiTitle, EuiSelect } from '@elastic/eui'; +import { useKibana } from '../../../../common/lib/kibana'; +import { ActionParamsProps } from '../../../../types'; +import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; +import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; + +import * as i18n from './translations'; +import { useChoices } from './use_choices'; +import { ServiceNowITOMActionParams } from './types'; +import { choicesToEuiOptions, isFieldInvalid } from './helpers'; + +const choicesFields = ['severity']; + +const fields: Array<{ + label: string; + fieldKey: keyof ServiceNowITOMActionParams['subActionParams']; +}> = [ + { label: i18n.SOURCE, fieldKey: 'source' }, + { label: i18n.NODE, fieldKey: 'node' }, + { label: i18n.TYPE, fieldKey: 'type' }, + { label: i18n.RESOURCE, fieldKey: 'resource' }, + { label: i18n.METRIC_NAME, fieldKey: 'metric_name' }, + { label: i18n.EVENT_CLASS, fieldKey: 'event_class' }, + { label: i18n.MESSAGE_KEY, fieldKey: 'message_key' }, +]; + +const additionalInformation = JSON.stringify({ + alert: { + id: '{{alert.id}}', + actionGroup: '{{alert.actionGroup}}', + actionSubgroup: '{{alert.actionSubgroup}}', + actionGroupName: '{{alert.actionGroupName}}', + }, + rule: { + id: '{{rule.id}}', + name: '{{rule.name}}', + type: '{{rule.type}}', + }, + date: '{{date}}', +}); + +const ServiceNowITOMParamsFields: React.FunctionComponent< + ActionParamsProps +> = ({ actionConnector, actionParams, editAction, index, messageVariables, errors }) => { + const params = useMemo( + () => (actionParams.subActionParams ?? {}) as ServiceNowITOMActionParams['subActionParams'], + [actionParams.subActionParams] + ); + + const { description, severity } = params; + + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const actionConnectorRef = useRef(actionConnector?.id ?? ''); + const { choices, isLoading: isLoadingChoices } = useChoices({ + http, + toastNotifications: toasts, + actionConnector, + fields: choicesFields, + }); + + const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); + + const editSubActionProperty = useCallback( + (key: string, value: any) => { + editAction('subActionParams', { ...params, [key]: value }, index); + }, + [editAction, index, params] + ); + + useEffect(() => { + if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) { + actionConnectorRef.current = actionConnector.id; + editAction( + 'subActionParams', + { additional_info: additionalInformation, message_key: '{{rule.id}}:{{alert.id}}' }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector]); + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'addEvent', index); + } + + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { additional_info: additionalInformation, message_key: '{{rule.id}}:{{alert.id}}' }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionParams]); + + return ( + <> + +

{i18n.EVENT}

+
+ + {fields.map((field) => ( + + + + + + + ))} + + editSubActionProperty('severity', e.target.value)} + isInvalid={isFieldInvalid(severity, errors.severity)} + /> + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowITOMParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 72f6d7635268f..42758250408d9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -152,7 +152,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< return ( <> -

{i18n.INCIDENT}

+

{i18n.SECURITY_INCIDENT}

; +const getChoicesMock = getChoices as jest.Mock; + +const actionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + name: 'ServiceNow ITSM', + isPreconfigured: false, + config: { + apiUrl: 'https://dev94428.service-now.com/', + }, +} as ActionConnector; + +const getChoicesResponse = [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, +]; + +const useChoicesResponse = { + isLoading: false, + choices: { category: getChoicesResponse }, +}; + +describe('UseChoices', () => { + const { services } = useKibanaMock(); + getChoicesMock.mockResolvedValue({ + data: getChoicesResponse, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const fields = ['category']; + + it('init', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual(useChoicesResponse); + }); + + it('returns an empty array if the field is not in response', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields: ['priority'], + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + isLoading: false, + choices: { priority: [], category: getChoicesResponse }, + }); + }); + + it('returns an empty array when connector is not presented', async () => { + const { result } = renderHook(() => + useChoices({ + http: services.http, + actionConnector: undefined, + toastNotifications: services.notifications.toasts, + fields, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + choices: { category: [] }, + }); + }); + + it('it displays an error when service fails', async () => { + getChoicesMock.mockResolvedValue({ + status: 'error', + service_message: 'An error occurred', + }); + + const { waitForNextUpdate } = renderHook(() => + useChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + }) + ); + + await waitForNextUpdate(); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); + + it('it displays an error when http throws an error', async () => { + getChoicesMock.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + renderHook(() => + useChoices({ + http: services.http, + actionConnector, + toastNotifications: services.notifications.toasts, + fields, + }) + ); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_choices.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_choices.tsx new file mode 100644 index 0000000000000..5493fdaee8bfa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_choices.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useCallback, useMemo, useState } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; + +import { ActionConnector } from '../../../../types'; +import { Choice, Fields } from './types'; +import { useGetChoices } from './use_get_choices'; + +export interface UseChoicesProps { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionConnector?: ActionConnector; + fields: string[]; +} + +export interface UseChoices { + choices: Fields; + isLoading: boolean; +} + +export const useChoices = ({ + http, + actionConnector, + toastNotifications, + fields, +}: UseChoicesProps): UseChoices => { + const defaultFields: Record = useMemo( + () => fields.reduce((acc, field) => ({ ...acc, [field]: [] }), {}), + [fields] + ); + const [choices, setChoices] = useState(defaultFields); + + const onChoicesSuccess = useCallback( + (values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ + ...acc, + [value.element]: [...(acc[value.element] ?? []), value], + }), + defaultFields + ) + ); + }, + [defaultFields] + ); + + const { isLoading } = useGetChoices({ + http, + toastNotifications, + actionConnector, + fields, + onSuccess: onChoicesSuccess, + }); + + return { choices, isLoading }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx index 01347a32e6da9..532789385e8bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.test.tsx @@ -121,7 +121,7 @@ describe('useGetChoices', () => { it('it displays an error when service fails', async () => { getChoicesMock.mockResolvedValue({ status: 'error', - serviceMessage: 'An error occurred', + service_message: 'An error occurred', }); const { waitForNextUpdate } = renderHook(() => @@ -162,4 +162,26 @@ describe('useGetChoices', () => { title: 'Unable to get choices', }); }); + + it('returns an empty array if the response is not an array', async () => { + getChoicesMock.mockResolvedValue({ + status: 'ok', + data: {}, + }); + + const { result } = renderHook(() => + useGetChoices({ + http: services.http, + actionConnector: undefined, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + choices: [], + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx index ef80c99955542..f4c881c633cdc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_choices.tsx @@ -60,15 +60,16 @@ export const useGetChoices = ({ }); if (!didCancel.current) { + const data = Array.isArray(res.data) ? res.data : []; setIsLoading(false); - setChoices(res.data ?? []); + setChoices(data); if (res.status && res.status === 'error') { toastNotifications.addDanger({ title: i18n.CHOICES_API_ERROR, - text: `${res.serviceMessage ?? res.message}`, + text: `${res.service_message ?? res.message}`, }); } else if (onSuccess) { - onSuccess(res.data ?? []); + onSuccess(data); } } } catch (error) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx index ff5992a6542b7..4192ee1a75a8f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx @@ -81,7 +81,7 @@ describe('health check', () => { expect(queryByText('should render')).toBeInTheDocument(); }); - test('renders warning if TLS is required', async () => { + test('renders warning if API keys are disabled', async () => { useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ is_sufficiently_secure: false, has_permanent_encryption_key: true, @@ -103,18 +103,17 @@ describe('health check', () => { // wait for useEffect to run }); - const [description, action] = queryAllByText(/TLS/i); + const [description] = queryAllByText(/API keys/i); + const [action] = queryAllByText(/Learn more/i); expect(description.textContent).toMatchInlineSnapshot( - `"Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. Learn how to enable TLS.(opens in a new tab or window)"` + `"You must enable API keys to use Alerting. Learn more.(opens in a new tab or window)"` ); - expect(action.textContent).toMatchInlineSnapshot( - `"Learn how to enable TLS.(opens in a new tab or window)"` - ); + expect(action.textContent).toMatchInlineSnapshot(`"Learn more.(opens in a new tab or window)"`); expect(action.getAttribute('href')).toMatchInlineSnapshot( - `"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/security-basic-setup.html#encrypt-internode-communication"` + `"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/security-settings.html#api-key-service-settings"` ); }); @@ -142,11 +141,13 @@ describe('health check', () => { const description = queryByRole(/banner/i); expect(description!.textContent).toMatchInlineSnapshot( - `"To create a rule, set a value for xpack.encryptedSavedObjects.encryptionKey in your kibana.yml file and ensure the Encrypted Saved Objects plugin is enabled. Learn how.(opens in a new tab or window)"` + `"You must configure an encryption key to use Alerting. Learn more.(opens in a new tab or window)"` ); const action = queryByText(/Learn/i); - expect(action!.textContent).toMatchInlineSnapshot(`"Learn how.(opens in a new tab or window)"`); + expect(action!.textContent).toMatchInlineSnapshot( + `"Learn more.(opens in a new tab or window)"` + ); expect(action!.getAttribute('href')).toMatchInlineSnapshot( `"https://www.elastic.co/guide/en/kibana/mocked-test-branch/alert-action-settings-kb.html#general-alert-action-settings"` ); @@ -175,14 +176,16 @@ describe('health check', () => { // wait for useEffect to run }); - const description = queryByText(/Transport Layer Security/i); + const description = queryByText(/You must enable/i); expect(description!.textContent).toMatchInlineSnapshot( - `"You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. Learn how.(opens in a new tab or window)"` + `"You must enable API keys and configure an encryption key to use Alerting. Learn more.(opens in a new tab or window)"` ); const action = queryByText(/Learn/i); - expect(action!.textContent).toMatchInlineSnapshot(`"Learn how.(opens in a new tab or window)"`); + expect(action!.textContent).toMatchInlineSnapshot( + `"Learn more.(opens in a new tab or window)"` + ); expect(action!.getAttribute('href')).toMatchInlineSnapshot( `"https://www.elastic.co/guide/en/kibana/mocked-test-branch/alerting-setup.html#alerting-prerequisites"` ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx index f990e12ed76e5..835c64ee0a05b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx @@ -13,7 +13,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiLink, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { EuiEmptyPrompt } from '@elastic/eui'; import { DocLinksStart } from 'kibana/public'; import './health_check.scss'; import { useHealthContext } from '../context/health_context'; @@ -82,11 +82,11 @@ export const HealthCheck: React.FunctionComponent = ({ ) : !healthCheck.isAlertsAvailable ? ( ) : !healthCheck.isSufficientlySecure && !healthCheck.hasPermanentEncryptionKey ? ( - + ) : !healthCheck.hasPermanentEncryptionKey ? ( ) : ( - + ); } ) @@ -108,7 +108,7 @@ const EncryptionError = ({ docLinks, className }: PromptErrorProps) => (

} @@ -118,22 +118,14 @@ const EncryptionError = ({ docLinks, className }: PromptErrorProps) => ( {i18n.translate( 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey', { - defaultMessage: 'To create a rule, set a value for ', - } - )} - {'xpack.encryptedSavedObjects.encryptionKey'} - {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey', - { - defaultMessage: - ' in your kibana.yml file and ensure the Encrypted Saved Objects plugin is enabled. ', + defaultMessage: 'You must configure an encryption key to use Alerting. ', } )} {i18n.translate( 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAction', { - defaultMessage: 'Learn how.', + defaultMessage: 'Learn more.', } )} @@ -143,7 +135,7 @@ const EncryptionError = ({ docLinks, className }: PromptErrorProps) => ( /> ); -const TlsError = ({ docLinks, className }: PromptErrorProps) => ( +const ApiKeysDisabledError = ({ docLinks, className }: PromptErrorProps) => ( ( title={

} body={

- {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsError', { - defaultMessage: - 'Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. ', + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.apiKeysDisabledError', { + defaultMessage: 'You must enable API keys to use Alerting. ', })} - - {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsErrorAction', { - defaultMessage: 'Learn how to enable TLS.', - })} + + {i18n.translate( + 'xpack.triggersActionsUI.components.healthCheck.apiKeysDisabledErrorAction', + { + defaultMessage: 'Learn more.', + } + )}

@@ -206,7 +204,7 @@ const AlertsError = ({ docLinks, className }: PromptErrorProps) => ( /> ); -const TlsAndEncryptionError = ({ docLinks, className }: PromptErrorProps) => ( +const ApiKeysAndEncryptionError = ({ docLinks, className }: PromptErrorProps) => ( ( title={

@@ -223,15 +221,18 @@ const TlsAndEncryptionError = ({ docLinks, className }: PromptErrorProps) => ( body={

- {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError', { - defaultMessage: - 'You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. ', - })} + {i18n.translate( + 'xpack.triggersActionsUI.components.healthCheck.apiKeysAndEncryptionError', + { + defaultMessage: + 'You must enable API keys and configure an encryption key to use Alerting. ', + } + )} {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction', + 'xpack.triggersActionsUI.components.healthCheck.apiKeysAndEncryptionErrorAction', { - defaultMessage: 'Learn how.', + defaultMessage: 'Learn more.', } )} diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts index b19c8b3d0f082..b2a1c4e80ec7d 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts @@ -9,4 +9,4 @@ export { setup as setupOverviewPage, OverviewTestBed } from './overview.helpers' export { setup as setupElasticsearchPage, ElasticsearchTestBed } from './elasticsearch.helpers'; export { setup as setupKibanaPage, KibanaTestBed } from './kibana.helpers'; -export { setupEnvironment } from './setup_environment'; +export { setupEnvironment, kibanaVersion } from './setup_environment'; diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx index a1cdfaa3446cb..fbbbc0e07853c 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx @@ -9,7 +9,7 @@ import React from 'react'; import axios from 'axios'; // @ts-ignore import axiosXhrAdapter from 'axios/lib/adapters/xhr'; - +import SemVer from 'semver/classes/semver'; import { deprecationsServiceMock, docLinksServiceMock, @@ -19,7 +19,7 @@ import { import { HttpSetup } from 'src/core/public'; import { KibanaContextProvider } from '../../../public/shared_imports'; -import { mockKibanaSemverVersion } from '../../../common/constants'; +import { MAJOR_VERSION } from '../../../common/constants'; import { AppContextProvider } from '../../../public/application/app_context'; import { apiService } from '../../../public/application/lib/api'; import { breadcrumbService } from '../../../public/application/lib/breadcrumbs'; @@ -31,6 +31,8 @@ const { GlobalFlyoutProvider } = GlobalFlyout; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); +export const kibanaVersion = new SemVer(MAJOR_VERSION); + export const WithAppDependencies = (Comp: any, overrides: Record = {}) => (props: Record) => { @@ -41,9 +43,9 @@ export const WithAppDependencies = http: mockHttpClient as unknown as HttpSetup, docLinks: docLinksServiceMock.createStartContract(), kibanaVersionInfo: { - currentMajor: mockKibanaSemverVersion.major, - prevMajor: mockKibanaSemverVersion.major - 1, - nextMajor: mockKibanaSemverVersion.major + 1, + currentMajor: kibanaVersion.major, + prevMajor: kibanaVersion.major - 1, + nextMajor: kibanaVersion.major + 1, }, notifications: notificationServiceMock.createStartContract(), isReadOnlyMode: false, diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx index 0acf5ae65c6cc..7831ab0110e4f 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/overview.test.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { mockKibanaSemverVersion } from '../../../common/constants'; -import { OverviewTestBed, setupOverviewPage, setupEnvironment } from '../helpers'; +import { OverviewTestBed, setupOverviewPage, setupEnvironment, kibanaVersion } from '../helpers'; describe('Overview Page', () => { let testBed: OverviewTestBed; @@ -24,7 +23,7 @@ describe('Overview Page', () => { describe('Documentation links', () => { test('Has a whatsNew link and it references nextMajor version', () => { const { exists, find } = testBed; - const nextMajor = mockKibanaSemverVersion.major + 1; + const nextMajor = kibanaVersion.major + 1; expect(exists('whatsNewLink')).toBe(true); expect(find('whatsNewLink').text()).toContain(`${nextMajor}.0`); diff --git a/x-pack/plugins/upgrade_assistant/common/config.ts b/x-pack/plugins/upgrade_assistant/common/config.ts deleted file mode 100644 index e74fe5cc1bf16..0000000000000 --- a/x-pack/plugins/upgrade_assistant/common/config.ts +++ /dev/null @@ -1,20 +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 { schema, TypeOf } from '@kbn/config-schema'; - -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - /* - * This will default to true up until the last minor before the next major. - * In readonly mode, the user will not be able to perform any actions in the UI - * and will be presented with a message indicating as such. - */ - readonly: schema.boolean({ defaultValue: true }), -}); - -export type Config = TypeOf; diff --git a/x-pack/plugins/upgrade_assistant/common/constants.ts b/x-pack/plugins/upgrade_assistant/common/constants.ts index 893d61d329534..68a6b9e9cdb83 100644 --- a/x-pack/plugins/upgrade_assistant/common/constants.ts +++ b/x-pack/plugins/upgrade_assistant/common/constants.ts @@ -4,15 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import SemVer from 'semver/classes/semver'; - /* - * These constants are used only in tests to add conditional logic based on Kibana version * On master, the version should represent the next major version (e.g., master --> 8.0.0) * The release branch should match the release version (e.g., 7.x --> 7.0.0) */ -export const mockKibanaVersion = '8.0.0'; -export const mockKibanaSemverVersion = new SemVer(mockKibanaVersion); +export const MAJOR_VERSION = '8.0.0'; /* * Map of 7.0 --> 8.0 index setting deprecation log messages and associated settings diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/warning_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/warning_step.test.tsx index ff11b9f1a8450..d2cafd69e94eb 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/warning_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/warning_step.test.tsx @@ -8,12 +8,20 @@ import { I18nProvider } from '@kbn/i18n/react'; import { mount, shallow } from 'enzyme'; import React from 'react'; +import SemVer from 'semver/classes/semver'; import { ReindexWarning } from '../../../../../../../common/types'; -import { mockKibanaSemverVersion } from '../../../../../../../common/constants'; +import { MAJOR_VERSION } from '../../../../../../../common/constants'; import { idForWarning, WarningsFlyoutStep } from './warnings_step'; +const kibanaVersion = new SemVer(MAJOR_VERSION); +const mockKibanaVersionInfo = { + currentMajor: kibanaVersion.major, + prevMajor: kibanaVersion.major - 1, + nextMajor: kibanaVersion.major + 1, +}; + jest.mock('../../../../../app_context', () => { const { docLinksServiceMock } = jest.requireActual( '../../../../../../../../../../src/core/public/doc_links/doc_links_service.mock' @@ -23,11 +31,7 @@ jest.mock('../../../../../app_context', () => { useAppContext: () => { return { docLinks: docLinksServiceMock.createStartContract(), - kibanaVersionInfo: { - currentMajor: mockKibanaSemverVersion.major, - prevMajor: mockKibanaSemverVersion.major - 1, - nextMajor: mockKibanaSemverVersion.major + 1, - }, + kibanaVersionInfo: mockKibanaVersionInfo, }; }, }; @@ -45,7 +49,7 @@ describe('WarningsFlyoutStep', () => { expect(shallow()).toMatchSnapshot(); }); - if (mockKibanaSemverVersion.major === 7) { + if (kibanaVersion.major === 7) { it('does not allow proceeding until all are checked', () => { const defaultPropsWithWarnings = { ...defaultProps, diff --git a/x-pack/plugins/upgrade_assistant/public/plugin.ts b/x-pack/plugins/upgrade_assistant/public/plugin.ts index 5edb638e1bc5b..32e825fbdc20d 100644 --- a/x-pack/plugins/upgrade_assistant/public/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/public/plugin.ts @@ -9,59 +9,69 @@ import SemVer from 'semver/classes/semver'; import { i18n } from '@kbn/i18n'; import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/public'; -import { SetupDependencies, StartDependencies, AppServicesContext } from './types'; -import { Config } from '../common/config'; +import { + SetupDependencies, + StartDependencies, + AppServicesContext, + ClientConfigType, +} from './types'; export class UpgradeAssistantUIPlugin implements Plugin { constructor(private ctx: PluginInitializerContext) {} + setup(coreSetup: CoreSetup, { management, cloud }: SetupDependencies) { - const { readonly } = this.ctx.config.get(); + const { + readonly, + ui: { enabled: isUpgradeAssistantUiEnabled }, + } = this.ctx.config.get(); - const appRegistrar = management.sections.section.stack; - const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); + if (isUpgradeAssistantUiEnabled) { + const appRegistrar = management.sections.section.stack; + const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); - const kibanaVersionInfo = { - currentMajor: kibanaVersion.major, - prevMajor: kibanaVersion.major - 1, - nextMajor: kibanaVersion.major + 1, - }; + const kibanaVersionInfo = { + currentMajor: kibanaVersion.major, + prevMajor: kibanaVersion.major - 1, + nextMajor: kibanaVersion.major + 1, + }; - const pluginName = i18n.translate('xpack.upgradeAssistant.appTitle', { - defaultMessage: '{version} Upgrade Assistant', - values: { version: `${kibanaVersionInfo.nextMajor}.0` }, - }); + const pluginName = i18n.translate('xpack.upgradeAssistant.appTitle', { + defaultMessage: '{version} Upgrade Assistant', + values: { version: `${kibanaVersionInfo.nextMajor}.0` }, + }); - appRegistrar.registerApp({ - id: 'upgrade_assistant', - title: pluginName, - order: 1, - async mount(params) { - const [coreStart, { discover, data }] = await coreSetup.getStartServices(); - const services: AppServicesContext = { discover, data, cloud }; + appRegistrar.registerApp({ + id: 'upgrade_assistant', + title: pluginName, + order: 1, + async mount(params) { + const [coreStart, { discover, data }] = await coreSetup.getStartServices(); + const services: AppServicesContext = { discover, data, cloud }; - const { - chrome: { docTitle }, - } = coreStart; + const { + chrome: { docTitle }, + } = coreStart; - docTitle.change(pluginName); + docTitle.change(pluginName); - const { mountManagementSection } = await import('./application/mount_management_section'); - const unmountAppCallback = await mountManagementSection( - coreSetup, - params, - kibanaVersionInfo, - readonly, - services - ); + const { mountManagementSection } = await import('./application/mount_management_section'); + const unmountAppCallback = await mountManagementSection( + coreSetup, + params, + kibanaVersionInfo, + readonly, + services + ); - return () => { - docTitle.reset(); - unmountAppCallback(); - }; - }, - }); + return () => { + docTitle.reset(); + unmountAppCallback(); + }; + }, + }); + } } start() {} diff --git a/x-pack/plugins/upgrade_assistant/public/types.ts b/x-pack/plugins/upgrade_assistant/public/types.ts index a2b49305c32d4..cbeaf22bb095b 100644 --- a/x-pack/plugins/upgrade_assistant/public/types.ts +++ b/x-pack/plugins/upgrade_assistant/public/types.ts @@ -26,3 +26,10 @@ export interface StartDependencies { discover: DiscoverStart; data: DataPublicPluginStart; } + +export interface ClientConfigType { + readonly: boolean; + ui: { + enabled: boolean; + }; +} diff --git a/x-pack/plugins/upgrade_assistant/server/config.ts b/x-pack/plugins/upgrade_assistant/server/config.ts new file mode 100644 index 0000000000000..4183ea337def1 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/config.ts @@ -0,0 +1,107 @@ +/* + * 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 { SemVer } from 'semver'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'src/core/server'; + +import { MAJOR_VERSION } from '../common/constants'; + +const kibanaVersion = new SemVer(MAJOR_VERSION); + +// ------------------------------- +// >= 8.x +// ------------------------------- +const schemaLatest = schema.object( + { + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + /* + * This will default to true up until the last minor before the next major. + * In readonly mode, the user will not be able to perform any actions in the UI + * and will be presented with a message indicating as such. + */ + readonly: schema.boolean({ defaultValue: true }), + }, + { defaultValue: undefined } +); + +const configLatest: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + readonly: true, + }, + schema: schemaLatest, + deprecations: () => [], +}; + +export type UpgradeAssistantConfig = TypeOf; + +// ------------------------------- +// 7.x +// ------------------------------- +const schema7x = schema.object( + { + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + /* + * This will default to true up until the last minor before the next major. + * In readonly mode, the user will not be able to perform any actions in the UI + * and will be presented with a message indicating as such. + */ + readonly: schema.boolean({ defaultValue: true }), + }, + { defaultValue: undefined } +); + +export type UpgradeAssistantConfig7x = TypeOf; + +const config7x: PluginConfigDescriptor = { + exposeToBrowser: { + ui: true, + readonly: true, + }, + schema: schema7x, + deprecations: () => [ + (completeConfig, rootPath, addDeprecation) => { + if (get(completeConfig, 'xpack.upgrade_assistant.enabled') === undefined) { + return completeConfig; + } + + addDeprecation({ + configPath: 'xpack.upgrade_assistant.enabled', + level: 'critical', + title: i18n.translate('xpack.upgradeAssistant.deprecations.enabledTitle', { + defaultMessage: 'Setting "xpack.upgrade_assistant.enabled" is deprecated', + }), + message: i18n.translate('xpack.upgradeAssistant.deprecations.enabledMessage', { + defaultMessage: + 'To disallow users from accessing the Upgrade Assistant UI, use the "xpack.upgrade_assistant.ui.enabled" setting instead of "xpack.upgrade_assistant.enabled".', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.upgradeAssistant.deprecations.enabled.manualStepOneMessage', { + defaultMessage: 'Open the kibana.yml config file.', + }), + i18n.translate('xpack.upgradeAssistant.deprecations.enabled.manualStepTwoMessage', { + defaultMessage: + 'Change the "xpack.upgrade_assistant.enabled" setting to "xpack.upgrade_assistant.ui.enabled".', + }), + ], + }, + }); + return completeConfig; + }, + ], +}; + +export const config: PluginConfigDescriptor = + kibanaVersion.major < 8 ? config7x : configLatest; diff --git a/x-pack/plugins/upgrade_assistant/server/index.ts b/x-pack/plugins/upgrade_assistant/server/index.ts index 5591276b2fa34..660aa107292e8 100644 --- a/x-pack/plugins/upgrade_assistant/server/index.ts +++ b/x-pack/plugins/upgrade_assistant/server/index.ts @@ -5,18 +5,11 @@ * 2.0. */ -import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; +import { PluginInitializerContext } from 'src/core/server'; import { UpgradeAssistantServerPlugin } from './plugin'; -import { configSchema, Config } from '../common/config'; + +export { config } from './config'; export const plugin = (ctx: PluginInitializerContext) => { return new UpgradeAssistantServerPlugin(ctx); }; - -export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], - schema: configSchema, - exposeToBrowser: { - readonly: true, - }, -}; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts index d93fe7920f1d7..5f39e902c75d9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts @@ -4,14 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { SemVer } from 'semver'; +import { MAJOR_VERSION } from '../../../common/constants'; -import { mockKibanaSemverVersion } from '../../../common/constants'; +const kibanaVersion = new SemVer(MAJOR_VERSION); export const getMockVersionInfo = () => { - const currentMajor = mockKibanaSemverVersion.major; + const currentMajor = kibanaVersion.major; return { - currentVersion: mockKibanaSemverVersion, + currentVersion: kibanaVersion, currentMajor, prevMajor: currentMajor - 1, nextMajor: currentMajor + 1, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts index e1817ef63927d..1785491e5da45 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts @@ -9,7 +9,7 @@ import { SemVer } from 'semver'; import { IScopedClusterClient, kibanaResponseFactory } from 'src/core/server'; import { coreMock } from 'src/core/server/mocks'; import { licensingMock } from '../../../../plugins/licensing/server/mocks'; -import { mockKibanaVersion } from '../../common/constants'; +import { MAJOR_VERSION } from '../../common/constants'; import { getMockVersionInfo } from './__fixtures__/version'; import { @@ -98,7 +98,7 @@ describe('verifyAllMatchKibanaVersion', () => { describe('EsVersionPrecheck', () => { beforeEach(() => { - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); }); it('returns a 403 when callCluster fails with a 403', async () => { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts index 30093a9fb6e50..957198cde8da9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockKibanaSemverVersion, mockKibanaVersion } from '../../../common/constants'; +import { MAJOR_VERSION } from '../../../common/constants'; import { versionService } from '../version'; import { getMockVersionInfo } from '../__fixtures__/version'; @@ -131,7 +131,7 @@ describe('transformFlatSettings', () => { describe('sourceNameForIndex', () => { beforeEach(() => { - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); }); it('parses internal indices', () => { @@ -152,7 +152,7 @@ describe('transformFlatSettings', () => { describe('generateNewIndexName', () => { beforeEach(() => { - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); }); it('parses internal indices', () => { @@ -186,7 +186,7 @@ describe('transformFlatSettings', () => { ).toEqual([]); }); - if (mockKibanaSemverVersion.major === 7) { + if (currentMajor === 7) { describe('[7.x] customTypeName warning', () => { it('returns customTypeName warning for non-_doc mapping types', () => { expect( diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts index 3cfdb1fdd3167..ce1e8e11eb2d1 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts @@ -19,7 +19,7 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; -import { mockKibanaVersion } from '../../../common/constants'; +import { MAJOR_VERSION } from '../../../common/constants'; import { versionService } from '../version'; import { LOCK_WINDOW, ReindexActions, reindexActionsFactory } from './reindex_actions'; import { getMockVersionInfo } from '../__fixtures__/version'; @@ -54,7 +54,7 @@ describe('ReindexActions', () => { describe('createReindexOp', () => { beforeEach(() => { - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); client.create.mockResolvedValue(); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts index 7a5bf1c187698..6017691a9328d 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts @@ -20,7 +20,7 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; -import { mockKibanaVersion } from '../../../common/constants'; +import { MAJOR_VERSION } from '../../../common/constants'; import { licensingMock } from '../../../../licensing/server/mocks'; import { LicensingPluginSetup } from '../../../../licensing/server'; @@ -89,7 +89,7 @@ describe('reindexService', () => { licensingPluginSetup ); - versionService.setup(mockKibanaVersion); + versionService.setup(MAJOR_VERSION); }); describe('hasRequiredPrivileges', () => { diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts index e7948f4ad532c..7b181ac2cf50c 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts @@ -22,6 +22,10 @@ export const JourneyStepType = t.intersection([ name: t.string, status: t.string, type: t.string, + timespan: t.type({ + gte: t.string, + lt: t.string, + }), }), synthetics: t.partial({ error: t.partial({ 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 45b162e13fb9b..c9299bf7bf3ea 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 @@ -353,8 +353,6 @@ exports[`DonutChart component passes correct props without errors for valid prop }, "fill": Object { "textBorder": 0, - "textContrast": true, - "textInvertible": true, }, "fontFamily": "'Inter', 'Inter UI', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx index 777491c503fd9..1e493ad21b4d3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx @@ -45,7 +45,11 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) )} {journey && activeStep && !journey.loading && ( - + )} ); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx index d249c23c44d75..95da0ea0a45f6 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/waterfall_chart_container.tsx @@ -15,6 +15,7 @@ import { networkEventsSelector } from '../../../../../state/selectors'; import { WaterfallChartWrapper } from './waterfall_chart_wrapper'; import { extractItems } from './data_formatting'; import { useStepWaterfallMetrics } from '../use_step_waterfall_metrics'; +import { JourneyStep } from '../../../../../../common/runtime_types'; export const NO_DATA_TEXT = i18n.translate('xpack.uptime.synthetics.stepDetail.waterfallNoData', { defaultMessage: 'No waterfall data could be found for this step', @@ -22,10 +23,11 @@ export const NO_DATA_TEXT = i18n.translate('xpack.uptime.synthetics.stepDetail.w interface Props { checkGroup: string; + activeStep?: JourneyStep; stepIndex: number; } -export const WaterfallChartContainer: React.FC = ({ checkGroup, stepIndex }) => { +export const WaterfallChartContainer: React.FC = ({ checkGroup, stepIndex, activeStep }) => { const dispatch = useDispatch(); useEffect(() => { @@ -79,6 +81,7 @@ export const WaterfallChartContainer: React.FC = ({ checkGroup, stepIndex data={extractItems(networkEvents.events)} markerItems={metrics} total={networkEvents.total} + activeStep={activeStep} /> )} {waterfallLoaded && hasEvents && !isWaterfallSupported && ( 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 8071fd1e3c4d3..aaa3d5c1cc23c 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 @@ -15,6 +15,7 @@ import { WaterfallFilter } from './waterfall_filter'; import { WaterfallFlyout } from './waterfall_flyout'; import { WaterfallSidebarItem } from './waterfall_sidebar_item'; import { MarkerItems } from '../../waterfall/context/waterfall_chart'; +import { JourneyStep } from '../../../../../../common/runtime_types'; export const renderLegendItem: RenderItem = (item) => { return ( @@ -26,11 +27,17 @@ export const renderLegendItem: RenderItem = (item) => { interface Props { total: number; + activeStep?: JourneyStep; data: NetworkItems; markerItems?: MarkerItems; } -export const WaterfallChartWrapper: React.FC = ({ data, total, markerItems }) => { +export const WaterfallChartWrapper: React.FC = ({ + data, + total, + markerItems, + activeStep, +}) => { const [query, setQuery] = useState(''); const [activeFilters, setActiveFilters] = useState([]); const [onlyHighlighted, setOnlyHighlighted] = useState(false); @@ -109,6 +116,7 @@ export const WaterfallChartWrapper: React.FC = ({ data, total, markerItem return ( ; + } + + return ( + setIsOpen(false)} + anchorPosition="downLeft" + panelStyle={{ paddingBottom: 0, paddingLeft: 4 }} + zIndex={100} + button={ + setIsOpen((prevState) => !prevState)} + /> + } + > + + + ); +} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_marker_trend.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_marker_trend.tsx new file mode 100644 index 0000000000000..6ff7835633914 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_marker_trend.tsx @@ -0,0 +1,106 @@ +/* + * 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 { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { useUptimeStartPlugins } from '../../../../../contexts/uptime_startup_plugins_context'; +import { useUptimeSettingsContext } from '../../../../../contexts/uptime_settings_context'; +import { AllSeries, createExploratoryViewUrl } from '../../../../../../../observability/public'; +import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { useWaterfallContext } from '../context/waterfall_chart'; +import { JourneyStep } from '../../../../../../common/runtime_types'; + +const getLast48Intervals = (activeStep: JourneyStep) => { + const { lt, gte } = activeStep.monitor.timespan!; + const inDays = moment(lt).diff(moment(gte), 'days'); + if (inDays > 0) { + return { to: 'now', from: `now-${inDays * 48}d` }; + } + + const inHours = moment(lt).diff(moment(gte), 'hours'); + if (inHours > 0) { + return { to: 'now', from: `now-${inHours * 48}h` }; + } + + const inMinutes = moment(lt).diff(moment(gte), 'minutes'); + if (inMinutes > 0) { + return { to: 'now', from: `now-${inMinutes * 48}m` }; + } + + const inSeconds = moment(lt).diff(moment(gte), 'seconds'); + return { to: 'now', from: `now-${inSeconds * 48}s` }; +}; + +export function WaterfallMarkerTrend({ title, field }: { title: string; field: string }) { + const { observability } = useUptimeStartPlugins(); + + const EmbeddableExpVIew = observability!.ExploratoryViewEmbeddable; + + const { basePath } = useUptimeSettingsContext(); + + const { activeStep } = useWaterfallContext(); + + if (!activeStep) { + return null; + } + + const allSeries: AllSeries = [ + { + name: `${title}(${activeStep.synthetics.step?.name!})`, + selectedMetricField: field, + time: getLast48Intervals(activeStep), + seriesType: 'area', + dataType: 'synthetics', + reportDefinitions: { + 'monitor.name': [activeStep.monitor.name!], + 'synthetics.step.name.keyword': [activeStep.synthetics.step?.name!], + }, + operationType: 'last_value', + }, + ]; + + const href = createExploratoryViewUrl( + { + reportType: 'kpi-over-time', + allSeries, + }, + basePath + ); + + return ( + + + {EXPLORE_LABEL} + + } + reportType={'kpi-over-time'} + attributes={allSeries} + axisTitlesVisibility={{ x: false, yLeft: false, yRight: false }} + legendIsVisible={false} + /> + + ); +} + +export const EXPLORE_LABEL = i18n.translate('xpack.uptime.synthetics.markers.explore', { + defaultMessage: 'Explore', +}); + +const Wrapper = euiStyled.div` + height: 200px; + width: 400px; + &&& { + .expExpressionRenderer__expression { + padding-bottom: 0 !important; + } + } +`; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_markers.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_markers.tsx index b341b052e0102..d8f6468015ede 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_markers.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_markers.tsx @@ -7,11 +7,11 @@ import React from 'react'; import { AnnotationDomainType, LineAnnotation } from '@elastic/charts'; -import { EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useWaterfallContext } from '..'; import { useTheme } from '../../../../../../../observability/public'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { WaterfallMarkerIcon } from './waterfall_marker_icon'; export const FCP_LABEL = i18n.translate('xpack.uptime.synthetics.waterfall.fcpLabel', { defaultMessage: 'First contentful paint', @@ -39,6 +39,12 @@ export const DOCUMENT_CONTENT_LOADED_LABEL = i18n.translate( } ); +export const SYNTHETICS_CLS = 'browser.experience.cls'; +export const SYNTHETICS_LCP = 'browser.experience.lcp.us'; +export const SYNTHETICS_FCP = 'browser.experience.fcp.us'; +export const SYNTHETICS_DOCUMENT_ONLOAD = 'browser.experience.load.us'; +export const SYNTHETICS_DCL = 'browser.experience.dcl.us'; + export function WaterfallChartMarkers() { const { markerItems } = useWaterfallContext(); @@ -48,12 +54,32 @@ export function WaterfallChartMarkers() { return null; } - const markersInfo: Record = { - domContentLoaded: { label: DOCUMENT_CONTENT_LOADED_LABEL, color: theme.eui.euiColorVis0 }, - firstContentfulPaint: { label: FCP_LABEL, color: theme.eui.euiColorVis1 }, - largestContentfulPaint: { label: LCP_LABEL, color: theme.eui.euiColorVis2 }, - layoutShift: { label: LAYOUT_SHIFT_LABEL, color: theme.eui.euiColorVis3 }, - loadEvent: { label: LOAD_EVENT_LABEL, color: theme.eui.euiColorVis9 }, + const markersInfo: Record = { + domContentLoaded: { + label: DOCUMENT_CONTENT_LOADED_LABEL, + color: theme.eui.euiColorVis0, + field: SYNTHETICS_DCL, + }, + firstContentfulPaint: { + label: FCP_LABEL, + color: theme.eui.euiColorVis1, + field: SYNTHETICS_FCP, + }, + largestContentfulPaint: { + label: LCP_LABEL, + color: theme.eui.euiColorVis2, + field: SYNTHETICS_LCP, + }, + layoutShift: { + label: LAYOUT_SHIFT_LABEL, + color: theme.eui.euiColorVis3, + field: SYNTHETICS_CLS, + }, + loadEvent: { + label: LOAD_EVENT_LABEL, + color: theme.eui.euiColorVis9, + field: SYNTHETICS_DOCUMENT_ONLOAD, + }, }; return ( @@ -73,7 +99,9 @@ export function WaterfallChartMarkers() { }), }, ]} - marker={} + marker={ + + } style={{ line: { strokeWidth: 2, 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 cce0533293e07..d495b7432bce7 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 @@ -9,6 +9,7 @@ import React, { createContext, useContext, Context } from 'react'; import { WaterfallData, WaterfallDataEntry, WaterfallMetadata } from '../types'; import { OnSidebarClick, OnElementClick, OnProjectionClick } from '../components/use_flyout'; import { SidebarItems } from '../../step_detail/waterfall/types'; +import { JourneyStep } from '../../../../../../common/runtime_types'; export type MarkerItems = Array<{ id: @@ -38,6 +39,7 @@ export interface IWaterfallContext { index?: number ) => JSX.Element; markerItems?: MarkerItems; + activeStep?: JourneyStep; } export const WaterfallContext = createContext>({}); @@ -56,6 +58,7 @@ interface ProviderProps { metadata: IWaterfallContext['metadata']; renderTooltipItem: IWaterfallContext['renderTooltipItem']; markerItems?: MarkerItems; + activeStep?: JourneyStep; } export const WaterfallProvider: React.FC = ({ @@ -73,11 +76,13 @@ export const WaterfallProvider: React.FC = ({ totalNetworkRequests, highlightedNetworkRequests, fetchedNetworkRequests, + activeStep, }) => { return ( >({}); @@ -14,3 +14,5 @@ export const UptimeStartupPluginsContextProvider: React.FC ; + +export const useUptimeStartPlugins = () => useContext(UptimeStartupPluginsContext); diff --git a/x-pack/test/accessibility/apps/lens.ts b/x-pack/test/accessibility/apps/lens.ts index ff769ddd29bfc..b8ddd774741b6 100644 --- a/x-pack/test/accessibility/apps/lens.ts +++ b/x-pack/test/accessibility/apps/lens.ts @@ -14,7 +14,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const listingTable = getService('listingTable'); - describe('Lens', () => { + // Failing: See https://github.com/elastic/kibana/issues/115614 + describe.skip('Lens', () => { const lensChartName = 'MyLensChart'; before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 0618d379dc77d..3fe5ecb6076e2 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -35,6 +35,7 @@ const enabledActionTypes = [ '.server-log', '.servicenow', '.servicenow-sir', + '.servicenow-itom', '.jira', '.resilient', '.slack', 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 19a789659fd7f..f5f08bd0de246 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 @@ -175,6 +175,14 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo }); } + if (pathName === '/api/global/em/jsonv2') { + return sendResponse(response, { + result: { + 'Default Bulk Endpoint': '1 events were inserted', + }, + }); + } + // Return an 400 error if endpoint is not supported response.statusCode = 400; response.setHeader('Content-Type', 'application/json'); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts index 7e8272b0a8afa..41a0d65b624b7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts @@ -231,10 +231,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts index 4421c984b4aed..8e2036ce688ea 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts @@ -233,10 +233,6 @@ export default function resilientTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts new file mode 100644 index 0000000000000..6f1ddc6ee2748 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itom.ts @@ -0,0 +1,342 @@ +/* + * 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 httpProxy from 'http-proxy'; +import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; + +import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function serviceNowITOMTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const mockServiceNow = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + subAction: 'addEvent', + subActionParams: { + source: 'A source', + event_class: 'An event class', + resource: 'C:', + node: 'node.example.com', + metric_name: 'Percentage Logical Disk Free Space', + type: 'Disk space', + severity: '4', + description: 'desc', + additional_info: '{"alert": "test"}', + message_key: 'a key', + time_of_event: '2021-10-13T10:51:44.981Z', + }, + }, + }; + + describe('ServiceNow ITOM', () => { + let simulatedActionId = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + before(async () => { + serviceNowServer = await getServiceNowServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!serviceNowServer.listening) { + serviceNowServer.listen(availablePort); + } + serviceNowSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + serviceNowSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + after(() => { + serviceNowServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('ServiceNow ITOM - Action Creation', () => { + it('should return 200 when creating a servicenow action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + }, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: 'http://servicenow.mynonexistent.com', + }, + secrets: mockServiceNow.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); + + describe('ServiceNow ITOM - Executor', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow-itom', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(simulatedActionId); + expect(resp.body.status).to.eql('error'); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + describe('getChoices', () => { + it('should fail when field is not provided', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: {}, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subActionParams.fields]: expected value of type [array] but got [undefined]', + }); + }); + }); + }); + }); + + describe('Execution', () => { + // New connectors + describe('Add event', () => { + it('should add an event ', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: mockServiceNow.params, + }) + .expect(200); + expect(result.status).to.eql('ok'); + }); + }); + + describe('getChoices', () => { + it('should get choices', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: { fields: ['priority'] }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts index 5ff1663975145..82f43ed4a3040 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts @@ -241,10 +241,6 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts index bc4ec43fb4c7b..0cdb279ac0746 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts @@ -245,10 +245,6 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts index 93d3a6c9e003f..9647d083460fd 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/swimlane.ts @@ -322,10 +322,6 @@ export default function swimlaneTest({ getService }: FtrProviderContext) { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - expect(resp.body.message).to.be( - `error validating action params: undefined is not iterable (cannot read property Symbol(Symbol.iterator))` - ); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 61bd1bcad34ad..d247e066226e9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -27,6 +27,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow_itsm')); loadTestFile(require.resolve('./builtin_action_types/servicenow_sir')); + loadTestFile(require.resolve('./builtin_action_types/servicenow_itom')); loadTestFile(require.resolve('./builtin_action_types/jira')); loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts index 055b4b69ab7a6..e7ea71863352e 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/delete.ts @@ -130,7 +130,7 @@ export default ({ getService }: FtrProviderContext) => { const destinationIndex = generateDestinationIndex(analyticsId); before(async () => { - await ml.api.createIndices(destinationIndex); + await ml.api.createIndex(destinationIndex); await ml.api.assertIndicesExist(destinationIndex); }); @@ -189,7 +189,7 @@ export default ({ getService }: FtrProviderContext) => { before(async () => { // Mimic real job by creating target index & index pattern after DFA job is created - await ml.api.createIndices(destinationIndex); + await ml.api.createIndex(destinationIndex); await ml.api.assertIndicesExist(destinationIndex); await ml.testResources.createIndexPatternIfNeeded(destinationIndex); }); diff --git a/x-pack/test/api_integration/apis/ml/job_validation/datafeed_preview_validation.ts b/x-pack/test/api_integration/apis/ml/job_validation/datafeed_preview_validation.ts new file mode 100644 index 0000000000000..c16050e08c886 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/job_validation/datafeed_preview_validation.ts @@ -0,0 +1,175 @@ +/* + * 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. + */ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; + +const farequoteMappings: estypes.MappingTypeMapping = { + properties: { + '@timestamp': { + type: 'date', + }, + airline: { + type: 'keyword', + }, + responsetime: { + type: 'float', + }, + }, +}; + +function getBaseJobConfig() { + return { + job_id: 'test', + description: '', + analysis_config: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + field_name: 'responsetime', + }, + ], + influencers: [], + }, + analysis_limits: { + model_memory_limit: '11MB', + }, + data_description: { + time_field: '@timestamp', + time_format: 'epoch_ms', + }, + model_plot_config: { + enabled: false, + annotations_enabled: false, + }, + model_snapshot_retention_days: 10, + daily_model_snapshot_retention_after_days: 1, + allow_lazy_open: false, + datafeed_config: { + query: { + bool: { + must: [ + { + match_all: {}, + }, + ], + }, + }, + indices: ['ft_farequote'], + scroll_size: 1000, + delayed_data_check_config: { + enabled: true, + }, + job_id: 'test', + datafeed_id: 'datafeed-test', + }, + }; +} + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + describe('Validate datafeed preview', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.api.createIndex('farequote_empty', farequoteMappings); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.api.deleteIndices('farequote_empty'); + }); + + it(`should validate a job with documents`, async () => { + const job = getBaseJobConfig(); + + const { body } = await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(200); + + expect(body.valid).to.eql(true, `valid should be true, but got ${body.valid}`); + expect(body.documentsFound).to.eql( + true, + `documentsFound should be true, but got ${body.documentsFound}` + ); + }); + + it(`should fail to validate a job with documents and non-existent field`, async () => { + const job = getBaseJobConfig(); + job.analysis_config.detectors[0].field_name = 'no_such_field'; + + const { body } = await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(200); + + expect(body.valid).to.eql(false, `valid should be false, but got ${body.valid}`); + expect(body.documentsFound).to.eql( + false, + `documentsFound should be false, but got ${body.documentsFound}` + ); + }); + + it(`should validate a job with no documents`, async () => { + const job = getBaseJobConfig(); + job.datafeed_config.indices = ['farequote_empty']; + + const { body } = await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(200); + + expect(body.valid).to.eql(true, `valid should be true, but got ${body.valid}`); + expect(body.documentsFound).to.eql( + false, + `documentsFound should be false, but got ${body.documentsFound}` + ); + }); + + it(`should fail for viewer user`, async () => { + const job = getBaseJobConfig(); + + await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(403); + }); + + it(`should fail for unauthorized user`, async () => { + const job = getBaseJobConfig(); + + await supertest + .post('/api/ml/validate/datafeed_preview') + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send({ job }) + .expect(403); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/job_validation/index.ts b/x-pack/test/api_integration/apis/ml/job_validation/index.ts index 4b75102d7b0bf..be07ae3b1852a 100644 --- a/x-pack/test/api_integration/apis/ml/job_validation/index.ts +++ b/x-pack/test/api_integration/apis/ml/job_validation/index.ts @@ -13,5 +13,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./calculate_model_memory_limit')); loadTestFile(require.resolve('./cardinality')); loadTestFile(require.resolve('./validate')); + loadTestFile(require.resolve('./datafeed_preview_validation')); }); } diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 762fc1642a87a..f234855b84e17 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -73,7 +73,7 @@ export default function ({ getService }: FtrProviderContext) { 'packs_read', ], }, - reserved: ['ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], + reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; await supertest diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 0efaa25ee57da..ac69bfcd9d5d4 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -45,7 +45,7 @@ export default function ({ getService }: FtrProviderContext) { }, global: ['all', 'read'], space: ['all', 'read'], - reserved: ['ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], + reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; await supertest diff --git a/x-pack/test/apm_api_integration/tests/transactions/latency_overall_distribution.ts b/x-pack/test/apm_api_integration/tests/transactions/latency_overall_distribution.ts index c915ac8911e37..b204f939020d7 100644 --- a/x-pack/test/apm_api_integration/tests/transactions/latency_overall_distribution.ts +++ b/x-pack/test/apm_api_integration/tests/transactions/latency_overall_distribution.ts @@ -12,17 +12,17 @@ import { registry } from '../../common/registry'; export default function ApiTest({ getService }: FtrProviderContext) { const apmApiClient = getService('apmApiClient'); - const endpoint = 'GET /internal/apm/latency/overall_distribution'; + const endpoint = 'POST /internal/apm/latency/overall_distribution'; // This matches the parameters used for the other tab's search strategy approach in `../correlations/*`. const getOptions = () => ({ params: { - query: { + body: { environment: 'ENVIRONMENT_ALL', start: '2020', end: '2021', kuery: '', - percentileThreshold: '95', + percentileThreshold: 95, }, }, }); 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 0aad3c699805a..223529fce54f6 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 @@ -411,6 +411,52 @@ export default ({ getService }: FtrProviderContext) => { expect(signalsOpen.hits.hits.length).equal(0); }); + describe('timeout behavior', () => { + it('will return an error if a rule execution exceeds the rule interval', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a short interval', + 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: '*:*', // broad query to take more time + threat_index: ['auditbeat-*'], // We use auditbeat as both the matching index and the threat list for simplicity + threat_mapping: [ + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + concurrent_searches: 1, + interval: '1s', // short interval + items_per_search: 1, // iterate only 1 threat item per loop to ensure we're slow + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id, 'failed'); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [id] }) + .expect(200); + expect(body[id].current_status.last_failure_message).to.contain( + 'execution has exceeded its allotted interval' + ); + }); + }); + describe('indicator enrichment', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/filebeat/threat_intel'); diff --git a/x-pack/test/fleet_api_integration/apis/agents/services.ts b/x-pack/test/fleet_api_integration/apis/agents/services.ts index be5d2d438f76f..0e28ad647bbc4 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/services.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/services.ts @@ -32,12 +32,30 @@ export function getEsClientForAPIKey({ getService }: FtrProviderContext, esApiKe }); } -export function setupFleetAndAgents({ getService }: FtrProviderContext) { +export function setupFleetAndAgents(providerContext: FtrProviderContext) { before(async () => { - await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send(); - await getService('supertest') + // Use elastic/fleet-server service account to execute setup to verify privilege configuration + const es = providerContext.getService('es'); + const { + body: { token }, + // @ts-expect-error SecurityCreateServiceTokenRequest should not require `name` + } = await es.security.createServiceToken({ + namespace: 'elastic', + service: 'fleet-server', + }); + const supetestWithoutAuth = getSupertestWithoutAuth(providerContext); + + await supetestWithoutAuth + .post(`/api/fleet/setup`) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Bearer ${token.value}`) + .send() + .expect(200); + await supetestWithoutAuth .post(`/api/fleet/agents/setup`) .set('kbn-xsrf', 'xxx') - .send({ forceRecreate: true }); + .set('Authorization', `Bearer ${token.value}`) + .send({ forceRecreate: true }) + .expect(200); }); } diff --git a/x-pack/test/fleet_api_integration/apis/epm/setup.ts b/x-pack/test/fleet_api_integration/apis/epm/setup.ts index 8567cf8069c58..051636ad11f5a 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/setup.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/setup.ts @@ -14,7 +14,9 @@ import { setupFleetAndAgents } from '../agents/services'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); const log = getService('log'); + const es = getService('es'); describe('setup api', async () => { skipIfNoDockerRegistry(providerContext); @@ -47,5 +49,48 @@ export default function (providerContext: FtrProviderContext) { ); }); }); + + it('allows elastic/fleet-server user to call required APIs', async () => { + const { + body: { token }, + // @ts-expect-error SecurityCreateServiceTokenRequest should not require `name` + } = await es.security.createServiceToken({ + namespace: 'elastic', + service: 'fleet-server', + }); + + // elastic/fleet-server needs access to these APIs: + // POST /api/fleet/setup + // POST /api/fleet/agents/setup + // GET /api/fleet/agent_policies + // GET /api/fleet/enrollment-api-keys + // GET /api/fleet/enrollment-api-keys/ + await supertestWithoutAuth + .post('/api/fleet/setup') + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + await supertestWithoutAuth + .post('/api/fleet/agents/setup') + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + await supertestWithoutAuth + .get('/api/fleet/agent_policies') + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + const response = await supertestWithoutAuth + .get('/api/fleet/enrollment-api-keys') + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + const enrollmentApiKeyId = response.body.list[0].id; + await supertestWithoutAuth + .get(`/api/fleet/enrollment-api-keys/${enrollmentApiKeyId}`) + .set('Authorization', `Bearer ${token.value}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + }); }); } diff --git a/x-pack/test/functional/apps/monitoring/index.js b/x-pack/test/functional/apps/monitoring/index.js index 6a5b6ea813171..a67964d325164 100644 --- a/x-pack/test/functional/apps/monitoring/index.js +++ b/x-pack/test/functional/apps/monitoring/index.js @@ -42,7 +42,7 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./beats/listing')); loadTestFile(require.resolve('./beats/beat_detail')); - // loadTestFile(require.resolve('./time_filter')); + loadTestFile(require.resolve('./time_filter')); loadTestFile(require.resolve('./enable_monitoring')); loadTestFile(require.resolve('./setup/metricbeat_migration')); diff --git a/x-pack/test/functional/apps/monitoring/time_filter.js b/x-pack/test/functional/apps/monitoring/time_filter.js index 910b91d07039d..76e7bc5cd043d 100644 --- a/x-pack/test/functional/apps/monitoring/time_filter.js +++ b/x-pack/test/functional/apps/monitoring/time_filter.js @@ -12,22 +12,44 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['header', 'timePicker']); const testSubjects = getService('testSubjects'); const clusterList = getService('monitoringClusterList'); + const browser = getService('browser'); + + const assertTimePickerRange = async (start, end) => { + const timeConfig = await PageObjects.timePicker.getTimeConfig(); + expect(timeConfig.start).to.eql(start); + expect(timeConfig.end).to.eql(end); + }; describe('Timefilter', () => { const { setup, tearDown } = getLifecycleMethods(getService, getPageObjects); + const from = 'Aug 15, 2017 @ 21:00:00.000'; + const to = 'Aug 16, 2017 @ 00:00:00.000'; + before(async () => { await setup('x-pack/test/functional/es_archives/monitoring/multicluster', { - from: 'Aug 15, 2017 @ 21:00:00.000', - to: 'Aug 16, 2017 @ 00:00:00.000', + from, + to, }); await clusterList.assertDefaults(); + await clusterList.closeAlertsModal(); }); after(async () => { await tearDown(); }); + it('syncs timepicker with url hash updates', async () => { + await assertTimePickerRange(from, to); + + await browser.execute(() => { + const hash = window.location.hash; + window.location.hash = hash.replace(/time:\(([^)]+)\)/, 'time:(from:now-15m,to:now)'); + }); + + await assertTimePickerRange('~ 15 minutes ago', 'now'); + }); + // FLAKY: https://github.com/elastic/kibana/issues/48910 it.skip('should send another request when clicking Refresh', async () => { await testSubjects.click('querySubmitButton'); diff --git a/x-pack/test/functional/services/ml/api.ts b/x-pack/test/functional/services/ml/api.ts index abde3bf365384..6ffd95f213c41 100644 --- a/x-pack/test/functional/services/ml/api.ts +++ b/x-pack/test/functional/services/ml/api.ts @@ -126,14 +126,20 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { ); }, - async createIndices(indices: string) { + async createIndex( + indices: string, + mappings?: Record | estypes.MappingTypeMapping + ) { log.debug(`Creating indices: '${indices}'...`); if ((await es.indices.exists({ index: indices, allow_no_indices: false })).body === true) { log.debug(`Indices '${indices}' already exist. Nothing to create.`); return; } - const { body } = await es.indices.create({ index: indices }); + const { body } = await es.indices.create({ + index: indices, + ...(mappings ? { body: { mappings } } : {}), + }); expect(body) .to.have.property('acknowledged') .eql(true, 'Response for create request indices should be acknowledged.'); diff --git a/x-pack/test/saved_object_tagging/functional/tests/som_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/som_integration.ts index 4688cfba4b864..e35b33b3b4475 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/som_integration.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/som_integration.ts @@ -21,10 +21,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { */ const selectTagsInFilter = async (...tagNames: string[]) => { // open the filter dropdown - // the first class selector before the id is of course useless. Only here to help cleaning that once we got - // testSubjects in EUI filters. + // This CSS selector should be cleaned up once we have testSubjects in EUI filters. const filterButton = await find.byCssSelector( - '.euiFilterGroup #field_value_selection_1 .euiFilterButton' + '.euiFilterGroup > *:last-child .euiFilterButton' ); await filterButton.click(); // select the tags diff --git a/x-pack/test/security_solution_endpoint/services/endpoint.ts b/x-pack/test/security_solution_endpoint/services/endpoint.ts index 2e774dcd84782..5bcc5c415a0db 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint.ts @@ -18,7 +18,7 @@ import { IndexedHostsAndAlertsResponse, indexHostsAndAlerts, } from '../../../plugins/security_solution/common/endpoint/index_data'; -import { TransformPivotConfig } from '../../../plugins/transform/common/types/transform'; +import { TransformConfigUnion } from '../../../plugins/transform/common/types/transform'; import { GetTransformsResponseSchema } from '../../../plugins/transform/common/api_schemas/transforms'; import { catchAndWrapError } from '../../../plugins/security_solution/server/endpoint/utils'; import { installOrUpgradeEndpointFleetPackage } from '../../../plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint'; @@ -38,9 +38,9 @@ export class EndpointTestResources extends FtrService { * * @param [endpointPackageVersion] if set, it will be used to get the specific transform this this package version. Else just returns first one found */ - async getTransform(endpointPackageVersion?: string): Promise { + async getTransform(endpointPackageVersion?: string): Promise { const transformId = this.generateTransformId(endpointPackageVersion); - let transform: TransformPivotConfig | undefined; + let transform: TransformConfigUnion | undefined; if (endpointPackageVersion) { await this.transform.api.waitForTransformToExist(transformId); diff --git a/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts b/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts index 28b19d5db20b6..c047a741e35da 100644 --- a/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts +++ b/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts @@ -50,6 +50,8 @@ export function getAggregatedSpaceData(es: KibanaClient, objectTypes: string[]) emit(doc["namespaces"].value); } else if (doc["namespace"].size() > 0) { emit(doc["namespace"].value); + } else if (doc["legacy-url-alias.targetNamespace"].size() > 0) { + emit(doc["legacy-url-alias.targetNamespace"].value); } `, }, diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index aaca4fa843d67..4bf44d88db8e0 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -48,6 +48,7 @@ export function deleteTestSuiteFactory( 'dashboard', 'space', 'index-pattern', + 'legacy-url-alias', // TODO: add assertions for config objects -- these assertions were removed because of flaky behavior in #92358, but we should // consider adding them again at some point, especially if we convert config objects to `namespaceType: 'multiple-isolated'` in // the future. @@ -56,6 +57,10 @@ export function deleteTestSuiteFactory( // @ts-expect-error @elastic/elasticsearch doesn't defined `count.buckets`. const buckets = response.aggregations?.count.buckets; + // The test fixture contains three legacy URL aliases: + // (1) one for "space_1", (2) one for "space_2", and (3) one for "other_space", which is a non-existent space. + // Each test deletes "space_2", so the agg buckets should reflect that aliases (1) and (3) still exist afterwards. + // Space 2 deleted, all others should exist const expectedBuckets = [ { @@ -65,47 +70,37 @@ export function deleteTestSuiteFactory( doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ - { - key: 'visualization', - doc_count: 3, - }, - { - key: 'dashboard', - doc_count: 2, - }, - { - key: 'space', - doc_count: 2, - }, - { - key: 'index-pattern', - doc_count: 1, - }, + { key: 'visualization', doc_count: 3 }, + { key: 'dashboard', doc_count: 2 }, + { key: 'space', doc_count: 2 }, // since space objects are namespace-agnostic, they appear in the "default" agg bucket + { key: 'index-pattern', doc_count: 1 }, + // legacy-url-alias objects cannot exist for the default space ], }, }, { - doc_count: 6, + doc_count: 7, key: 'space_1', countByType: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ - { - key: 'visualization', - doc_count: 3, - }, - { - key: 'dashboard', - doc_count: 2, - }, - { - key: 'index-pattern', - doc_count: 1, - }, + { key: 'visualization', doc_count: 3 }, + { key: 'dashboard', doc_count: 2 }, + { key: 'index-pattern', doc_count: 1 }, + { key: 'legacy-url-alias', doc_count: 1 }, // alias (1) ], }, }, + { + doc_count: 1, + key: 'other_space', + countByType: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'legacy-url-alias', doc_count: 1 }], // alias (3) + }, + }, ]; expect(buckets).to.eql(expectedBuckets); diff --git a/yarn.lock b/yarn.lock index e5e2f59359c9f..375e748564283 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2412,10 +2412,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@39.0.0": - version "39.0.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-39.0.0.tgz#abac19edd466eee13612d5668e5456961dc813b8" - integrity sha512-8sf8sbxjpRxV23dFTwbkaWH6LhWrOlMpdUUMVUC9zd0g5iQLj1IxkxQCeyYM/p++SQFl+1hshAuaH//DCz5Xrw== +"@elastic/eui@39.1.1": + version "39.1.1" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-39.1.1.tgz#52e59f1dd6448b2e80047259ca60c6c87e9873f0" + integrity sha512-zYCNitpp6Ds7U6eaa9QkJqc20ZMo2wjpZokNtd1WalFV22vdfiVizFg7DMtDjJrCDLmoXcLOOCMasKlmmJ1cRg== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" @@ -2441,7 +2441,7 @@ react-is "~16.3.0" react-virtualized-auto-sizer "^1.0.2" react-window "^1.8.5" - refractor "^3.4.0" + refractor "^3.5.0" rehype-raw "^5.0.0" rehype-react "^6.0.0" rehype-stringify "^8.0.0" @@ -5782,11 +5782,6 @@ dependencies: "@turf/helpers" "6.x" -"@types/angular@^1.6.56": - version "1.6.56" - resolved "https://registry.yarnpkg.com/@types/angular/-/angular-1.6.56.tgz#20124077bd44061e018c7283c0bb83f4b00322dd" - integrity sha512-HxtqilvklZ7i6XOaiP7uIJIrFXEVEhfbSY45nfv2DeBRngncI58Y4ZOUMiUkcT8sqgLL1ablmbfylChUg7A3GA== - "@types/anymatch@*": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" @@ -7854,31 +7849,6 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= -angular-aria@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/angular-aria/-/angular-aria-1.8.0.tgz#97aec9b1e8bafd07d5fab30f98d8ec832e18e25d" - integrity sha512-eCQI6EwgY6bYHdzIUfDABHnZjoZ3bNYpCsnceQF4bLfbq1QtZ7raRPNca45sj6C9Pfjde6PNcEDvuLozFPYnrQ== - -angular-recursion@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/angular-recursion/-/angular-recursion-1.0.5.tgz#cd405428a0bf55faf52eaa7988c1fe69cd930543" - integrity sha1-zUBUKKC/Vfr1Lqp5iMH+ac2TBUM= - -angular-route@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/angular-route/-/angular-route-1.8.0.tgz#cb8066c5d34284ffd6a15ac7be1b3d51c5ad7bb2" - integrity sha512-ORvXAdVfCCA6XFwyjSkVQFFGufj0mNGiCvBR93Qsii8+7t/6Ioy6wheUoCj1x4NWUv7hAq3nYYGCVO6QEKb1BQ== - -angular-sanitize@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/angular-sanitize/-/angular-sanitize-1.8.0.tgz#9f80782d3afeec3bcc0bb92b3ca6f1f421cfbca6" - integrity sha512-j5GiOPCvfcDWK5svEOVoPb11X3UDVy/mdHPRWuy14Iyw86xaq+Bb+x/em2sAOa5MQQeY5ciLXbF3RRp8iCKcNg== - -angular@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/angular/-/angular-1.8.0.tgz#b1ec179887869215cab6dfd0df2e42caa65b1b51" - integrity sha512-VdaMx+Qk0Skla7B5gw77a8hzlcOakwF8mjlW13DpIWIDlfqwAbSSLfd8N/qZnzEmQF4jC4iofInd3gE7vL8ZZg== - ansi-align@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb" @@ -24731,7 +24701,7 @@ reflect.ownkeys@^0.2.0: resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= -refractor@^3.2.0, refractor@^3.4.0: +refractor@^3.2.0, refractor@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.5.0.tgz#334586f352dda4beaf354099b48c2d18e0819aec" integrity sha512-QwPJd3ferTZ4cSPPjdP5bsYHMytwWYnAN5EEnLtGvkqp/FCCnGsBgxrm9EuIDnjUC3Uc/kETtvVi7fSIVC74Dg==