diff --git a/.ci/teamcity/checks/doc_api_changes.sh b/.ci/teamcity/checks/doc_api_changes.sh index 821647a39441c..43b65d4e188ba 100755 --- a/.ci/teamcity/checks/doc_api_changes.sh +++ b/.ci/teamcity/checks/doc_api_changes.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:checkDocApiChanges +checks-reporter-with-killswitch "Check Doc API Changes" \ + node scripts/check_published_api_changes diff --git a/.ci/teamcity/checks/eslint.sh b/.ci/teamcity/checks/eslint.sh new file mode 100755 index 0000000000000..d7282b310f81c --- /dev/null +++ b/.ci/teamcity/checks/eslint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +checks-reporter-with-killswitch "Lint: eslint" \ + node scripts/eslint --no-cache diff --git a/.ci/teamcity/checks/file_casing.sh b/.ci/teamcity/checks/file_casing.sh index 66578a4970fec..5c0815bdd9551 100755 --- a/.ci/teamcity/checks/file_casing.sh +++ b/.ci/teamcity/checks/file_casing.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:checkFileCasing +checks-reporter-with-killswitch "Check File Casing" \ + node scripts/check_file_casing --quiet diff --git a/.ci/teamcity/checks/i18n.sh b/.ci/teamcity/checks/i18n.sh index f269816cf6b95..62ea3fbe9b04d 100755 --- a/.ci/teamcity/checks/i18n.sh +++ b/.ci/teamcity/checks/i18n.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:i18nCheck +checks-reporter-with-killswitch "Check i18n" \ + node scripts/i18n_check --ignore-missing diff --git a/.ci/teamcity/checks/licenses.sh b/.ci/teamcity/checks/licenses.sh index 2baca87074630..136d281647cc5 100755 --- a/.ci/teamcity/checks/licenses.sh +++ b/.ci/teamcity/checks/licenses.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:licenses +checks-reporter-with-killswitch "Check Licenses" \ + node scripts/check_licenses --dev diff --git a/.ci/teamcity/checks/verify_dependency_versions.sh b/.ci/teamcity/checks/sasslint.sh similarity index 51% rename from .ci/teamcity/checks/verify_dependency_versions.sh rename to .ci/teamcity/checks/sasslint.sh index 4c2ddf5ce8612..45b90f6a8034e 100755 --- a/.ci/teamcity/checks/verify_dependency_versions.sh +++ b/.ci/teamcity/checks/sasslint.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:verifyDependencyVersions +checks-reporter-with-killswitch "Lint: sasslint" \ + node scripts/sasslint diff --git a/.ci/teamcity/checks/telemetry.sh b/.ci/teamcity/checks/telemetry.sh index 6413584d2057d..034dd6d647ad3 100755 --- a/.ci/teamcity/checks/telemetry.sh +++ b/.ci/teamcity/checks/telemetry.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:telemetryCheck +checks-reporter-with-killswitch "Check Telemetry Schema" \ + node scripts/telemetry_check diff --git a/.ci/teamcity/checks/test_hardening.sh b/.ci/teamcity/checks/test_hardening.sh index 21ee68e5ade70..5799a0b44133b 100755 --- a/.ci/teamcity/checks/test_hardening.sh +++ b/.ci/teamcity/checks/test_hardening.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:test_hardening +checks-reporter-with-killswitch "Test Hardening" \ + node scripts/test_hardening diff --git a/.ci/teamcity/checks/ts_projects.sh b/.ci/teamcity/checks/ts_projects.sh index 8afc195fee555..9d1c898090def 100755 --- a/.ci/teamcity/checks/ts_projects.sh +++ b/.ci/teamcity/checks/ts_projects.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:checkTsProjects +checks-reporter-with-killswitch "Check TypeScript Projects" \ + node scripts/check_ts_projects diff --git a/.ci/teamcity/checks/type_check.sh b/.ci/teamcity/checks/type_check.sh index da8ae3373d976..d465e8f4c52b4 100755 --- a/.ci/teamcity/checks/type_check.sh +++ b/.ci/teamcity/checks/type_check.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:typeCheck +checks-reporter-with-killswitch "Check Types" \ + node scripts/type_check diff --git a/.ci/teamcity/checks/verify_notice.sh b/.ci/teamcity/checks/verify_notice.sh index 8571e0bbceb13..636dc35555f67 100755 --- a/.ci/teamcity/checks/verify_notice.sh +++ b/.ci/teamcity/checks/verify_notice.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:verifyNotice +checks-reporter-with-killswitch "Verify NOTICE" \ + node scripts/notice --validate diff --git a/.ci/teamcity/default/jest.sh b/.ci/teamcity/default/jest.sh new file mode 100755 index 0000000000000..93ca7f76f3a21 --- /dev/null +++ b/.ci/teamcity/default/jest.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-default-jest + +cd "$XPACK_DIR" + +checks-reporter-with-killswitch "Jest Unit Tests" \ + node scripts/jest --bail --debug diff --git a/.ci/teamcity/oss/api_integration.sh b/.ci/teamcity/oss/api_integration.sh new file mode 100755 index 0000000000000..37241bdbdc075 --- /dev/null +++ b/.ci/teamcity/oss/api_integration.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-oss-api-integration + +checks-reporter-with-killswitch "API Integration Tests" \ + node scripts/functional_tests --config test/api_integration/config.js --bail --debug diff --git a/.ci/teamcity/oss/jest.sh b/.ci/teamcity/oss/jest.sh new file mode 100755 index 0000000000000..3ba9ab0c31c57 --- /dev/null +++ b/.ci/teamcity/oss/jest.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-oss-jest + +checks-reporter-with-killswitch "OSS Jest Unit Tests" \ + node scripts/jest --ci --verbose diff --git a/.ci/teamcity/oss/jest_integration.sh b/.ci/teamcity/oss/jest_integration.sh new file mode 100755 index 0000000000000..1a23c46c8a2c2 --- /dev/null +++ b/.ci/teamcity/oss/jest_integration.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-oss-jest-integration + +checks-reporter-with-killswitch "OSS Jest Integration Tests" \ + node scripts/jest_integration --verbose diff --git a/.ci/teamcity/oss/plugin_functional.sh b/.ci/teamcity/oss/plugin_functional.sh index 41ff549945c0b..5d1ecbcbd48ee 100755 --- a/.ci/teamcity/oss/plugin_functional.sh +++ b/.ci/teamcity/oss/plugin_functional.sh @@ -13,6 +13,6 @@ if [[ ! -d "target" ]]; then fi cd - -yarn run grunt run:pluginFunctionalTestsRelease --from=source -yarn run grunt run:exampleFunctionalTestsRelease --from=source -yarn run grunt run:interpreterFunctionalTestsRelease +./test/scripts/test/plugin_functional.sh +./test/scripts/test/example_functional.sh +./test/scripts/test/interpreter_functional.sh diff --git a/.ci/teamcity/oss/server_integration.sh b/.ci/teamcity/oss/server_integration.sh new file mode 100755 index 0000000000000..ddeef77907c49 --- /dev/null +++ b/.ci/teamcity/oss/server_integration.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-oss-server-integration +export KIBANA_INSTALL_DIR="$PARENT_DIR/build/kibana-build-oss" + +checks-reporter-with-killswitch "Server integration tests" \ + node scripts/functional_tests \ + --config test/server_integration/http/ssl/config.js \ + --config test/server_integration/http/ssl_redirect/config.js \ + --config test/server_integration/http/platform/config.ts \ + --config test/server_integration/http/ssl_with_p12/config.js \ + --config test/server_integration/http/ssl_with_p12_intermediate/config.js \ + --bail \ + --debug \ + --kibana-install-dir $KIBANA_INSTALL_DIR diff --git a/.ci/teamcity/tests/mocha.sh b/.ci/teamcity/tests/mocha.sh index ea6c43c39e397..acb088220fa78 100755 --- a/.ci/teamcity/tests/mocha.sh +++ b/.ci/teamcity/tests/mocha.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:mocha +checks-reporter-with-killswitch "Mocha Tests" \ + node scripts/mocha diff --git a/.ci/teamcity/tests/test_hardening.sh b/.ci/teamcity/tests/test_hardening.sh deleted file mode 100755 index 21ee68e5ade70..0000000000000 --- a/.ci/teamcity/tests/test_hardening.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -source "$(dirname "${0}")/../util.sh" - -yarn run grunt run:test_hardening diff --git a/.ci/teamcity/tests/test_projects.sh b/.ci/teamcity/tests/test_projects.sh index 3feaa821424e1..2553650930392 100755 --- a/.ci/teamcity/tests/test_projects.sh +++ b/.ci/teamcity/tests/test_projects.sh @@ -4,4 +4,5 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" -yarn run grunt run:test_projects +checks-reporter-with-killswitch "Test Projects" \ + yarn kbn run test --exclude kibana --oss --skip-kibana-plugins diff --git a/.teamcity/src/builds/Lint.kt b/.teamcity/src/builds/Lint.kt index 0b3b3b013b5ec..d02f1c9038aca 100644 --- a/.teamcity/src/builds/Lint.kt +++ b/.teamcity/src/builds/Lint.kt @@ -17,7 +17,7 @@ object Lint : BuildType({ scriptContent = """ #!/bin/bash - yarn run grunt run:sasslint + ./.ci/teamcity/checks/sasslint.sh """.trimIndent() } @@ -26,7 +26,7 @@ object Lint : BuildType({ scriptContent = """ #!/bin/bash - yarn run grunt run:eslint + ./.ci/teamcity/checks/eslint.sh """.trimIndent() } } diff --git a/.teamcity/src/builds/test/ApiServerIntegration.kt b/.teamcity/src/builds/test/ApiServerIntegration.kt index d595840c879e6..ca58b628cbd22 100644 --- a/.teamcity/src/builds/test/ApiServerIntegration.kt +++ b/.teamcity/src/builds/test/ApiServerIntegration.kt @@ -9,8 +9,8 @@ object ApiServerIntegration : BuildType({ description = "Executes API and Server Integration Tests" steps { - runbld("API Integration", "yarn run grunt run:apiIntegrationTests") - runbld("Server Integration", "yarn run grunt run:serverIntegrationTests") + runbld("API Integration", "./.ci/teamcity/oss/api_integration.sh") + runbld("Server Integration", "./.ci/teamcity/oss/server_integration.sh") } addTestSettings() diff --git a/.teamcity/src/builds/test/Jest.kt b/.teamcity/src/builds/test/Jest.kt index 04217a4e99b1c..c33c9c2678ca4 100644 --- a/.teamcity/src/builds/test/Jest.kt +++ b/.teamcity/src/builds/test/Jest.kt @@ -12,7 +12,7 @@ object Jest : BuildType({ kibanaAgent(8) steps { - runbld("Jest Unit", "yarn run grunt run:test_jest") + runbld("Jest Unit", "./.ci/teamcity/oss/jest.sh") } addTestSettings() diff --git a/.teamcity/src/builds/test/JestIntegration.kt b/.teamcity/src/builds/test/JestIntegration.kt index 9ec1360dcb1d7..7d44e41493b2b 100644 --- a/.teamcity/src/builds/test/JestIntegration.kt +++ b/.teamcity/src/builds/test/JestIntegration.kt @@ -9,7 +9,7 @@ object JestIntegration : BuildType({ description = "Executes Jest Integration Tests" steps { - runbld("Jest Integration", "yarn run grunt run:test_jest_integration") + runbld("Jest Integration", "./.ci/teamcity/oss/jest_integration.sh") } addTestSettings() diff --git a/.teamcity/src/builds/test/QuickTests.kt b/.teamcity/src/builds/test/QuickTests.kt index cca10cc3f2aa2..5b1d2541480ad 100644 --- a/.teamcity/src/builds/test/QuickTests.kt +++ b/.teamcity/src/builds/test/QuickTests.kt @@ -12,7 +12,7 @@ object QuickTests : BuildType({ kibanaAgent(2) val testScripts = mapOf( - "Test Hardening" to ".ci/teamcity/tests/test_hardening.sh", + "Test Hardening" to ".ci/teamcity/checkes/test_hardening.sh", "Test Projects" to ".ci/teamcity/tests/test_projects.sh", "Mocha Tests" to ".ci/teamcity/tests/mocha.sh" ) diff --git a/.teamcity/src/builds/test/XPackJest.kt b/.teamcity/src/builds/test/XPackJest.kt index 1958d39183bae..8246b60823ff9 100644 --- a/.teamcity/src/builds/test/XPackJest.kt +++ b/.teamcity/src/builds/test/XPackJest.kt @@ -12,10 +12,7 @@ object XPackJest : BuildType({ kibanaAgent(16) steps { - runbld("X-Pack Jest Unit", """ - cd x-pack - node --max-old-space-size=6144 scripts/jest --ci --verbose --maxWorkers=6 - """.trimIndent()) + runbld("X-Pack Jest Unit", "./.ci/teamcity/default/jest.sh") } addTestSettings() diff --git a/package.json b/package.json index 513d9b907c96c..f2786871fb629 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "test:jest": "node scripts/jest", "test:jest_integration": "node scripts/jest_integration", "test:mocha": "node scripts/mocha", - "test:mocha:coverage": "grunt test:mochaCoverage", "test:ftr": "node scripts/functional_tests", "test:ftr:server": "node scripts/functional_tests_server", "test:ftr:runner": "node scripts/functional_test_runner", @@ -674,7 +673,6 @@ "grunt-contrib-copy": "^1.0.0", "grunt-contrib-watch": "^1.1.0", "grunt-peg": "^2.0.1", - "grunt-run": "0.8.1", "gulp": "4.0.2", "gulp-babel": "^8.0.0", "gulp-sourcemaps": "2.6.5", diff --git a/packages/kbn-spec-to-console/package.json b/packages/kbn-spec-to-console/package.json index f1eefab0f1fd0..b8947d1b3b6d0 100644 --- a/packages/kbn-spec-to-console/package.json +++ b/packages/kbn-spec-to-console/package.json @@ -7,7 +7,6 @@ "lib": "lib" }, "scripts": { - "test": "../../node_modules/.bin/jest", "format": "../../node_modules/.bin/prettier **/*.js --write" }, "author": "", diff --git a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts index 10a30db038174..3f61db4292604 100644 --- a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts +++ b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts @@ -19,10 +19,10 @@ import Chance from 'chance'; +import { getUpgradeableConfigMock } from './get_upgradeable_config.test.mock'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; import { savedObjectsClientMock } from '../../saved_objects/service/saved_objects_client.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; -import { getUpgradeableConfigMock } from './get_upgradeable_config.test.mock'; import { createOrUpgradeSavedConfig } from './create_or_upgrade_saved_config'; diff --git a/src/dev/code_coverage/shell_scripts/extract_archives.sh b/src/dev/code_coverage/shell_scripts/extract_archives.sh index 376467f9f2e55..32b4ccd6abccb 100644 --- a/src/dev/code_coverage/shell_scripts/extract_archives.sh +++ b/src/dev/code_coverage/shell_scripts/extract_archives.sh @@ -6,7 +6,7 @@ EXTRACT_DIR=/tmp/extracted_coverage mkdir -p $EXTRACT_DIR echo "### Extracting downloaded artifacts" -for x in kibana-intake x-pack-intake kibana-oss-tests kibana-xpack-tests; do +for x in kibana-intake kibana-oss-tests kibana-xpack-tests; do #x-pack-intake skipping due to failures tar -xzf $DOWNLOAD_DIR/coverage/${x}/kibana-coverage.tar.gz -C $EXTRACT_DIR || echo "### Error 'tarring': ${x}" done diff --git a/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh b/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh index 62b81929ae79b..5d983828394bf 100644 --- a/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh +++ b/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh @@ -32,7 +32,7 @@ TEAM_ASSIGN_PATH=$5 # Build team assignments dat file node scripts/generate_team_assignments.js --verbose --src .github/CODEOWNERS --dest $TEAM_ASSIGN_PATH -for x in jest functional; do +for x in functional; do #jest skip due to failures echo "### Ingesting coverage for ${x}" COVERAGE_SUMMARY_FILE=target/kibana-coverage/${x}-combined/coverage-summary.json diff --git a/src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh b/src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh index 707c6de3f88a0..a8952f987b419 100644 --- a/src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh +++ b/src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh @@ -4,6 +4,6 @@ COVERAGE_TEMP_DIR=/tmp/extracted_coverage/target/kibana-coverage/ export COVERAGE_TEMP_DIR echo "### Merge coverage reports" -for x in jest functional; do +for x in functional; do # jest skip due to failures yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.${x}.config.js done diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap index d68011d2f7fde..e817e898cca67 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap @@ -492,6 +492,7 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` findItems={[Function]} headingId="dashboardListingHeading" initialFilter="" + initialPageSize={10} listingLimit={1000} noItemsFragment={
diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js index 99b1ebf047d74..cc2c0a2e828ca 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js @@ -62,6 +62,7 @@ test('renders empty page in before initial fetch to avoid flickering', () => { getViewUrl={() => {}} listingLimit={1000} hideWriteControls={false} + initialPageSize={10} core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} /> ); diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index d0c6f0456a8f1..ec5174df50f13 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -341,6 +341,21 @@ describe('SearchSource', () => { const request = await searchSource.getSearchRequestBody(); expect(request.script_fields).toEqual({ hello: {} }); }); + + test('returns all scripted fields when one fields entry is *', async () => { + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: { hello: {}, world: {} }, + docvalueFields: [], + }), + } as unknown) as IndexPattern); + searchSource.setField('fields', ['timestamp', '*']); + + const request = await searchSource.getSearchRequestBody(); + expect(request.script_fields).toEqual({ hello: {}, world: {} }); + }); }); describe('handling for when specific fields are provided', () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 2206d6d2816e2..fce0b737b962b 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -408,7 +408,12 @@ export class SearchSource { case 'query': return addToRoot(key, (data[key] || []).concat(val)); case 'fields': - // uses new Fields API + // This will pass the passed in parameters to the new fields API. + // Also if will only return scripted fields that are part of the specified + // array of fields. If you specify the wildcard `*` as an array element + // the fields API will return all fields, and all scripted fields will be returned. + // NOTE: While the fields API supports wildcards within names, e.g. `user.*` + // scripted fields won't be considered for this. return addToBody('fields', val); case 'fieldsFromSource': // preserves legacy behavior @@ -518,11 +523,13 @@ export class SearchSource { ); const uniqFieldNames = [...new Set([...bodyFieldNames, ...fieldsFromSource])]; - // filter down script_fields to only include items specified - body.script_fields = pick( - body.script_fields, - Object.keys(body.script_fields).filter((f) => uniqFieldNames.includes(f)) - ); + if (!uniqFieldNames.includes('*')) { + // filter down script_fields to only include items specified + body.script_fields = pick( + body.script_fields, + Object.keys(body.script_fields).filter((f) => uniqFieldNames.includes(f)) + ); + } // request the remaining fields from stored_fields just in case, since the // fields API does not handle stored fields diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 7ef337eb89c85..6c8683220bc4c 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -106,13 +106,15 @@ export class SearchService implements Plugin { private readonly searchSourceService = new SearchSourceService(); private defaultSearchStrategyName: string = ES_SEARCH_STRATEGY; private searchStrategies: StrategyMap = {}; + private sessionService: ISessionService; private coreStart?: CoreStart; - private sessionService: ISessionService = new SessionService(); constructor( private initializerContext: PluginInitializerContext, private readonly logger: Logger - ) {} + ) { + this.sessionService = new SessionService(); + } public setup( core: CoreSetup<{}, DataPluginStart>, diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx index f9be9d5bfade7..bcd9d31dade26 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { wait } from '@testing-library/dom'; +import { waitFor } from '@testing-library/dom'; import { render } from '@testing-library/react'; import { HelloWorldEmbeddable, @@ -47,7 +47,7 @@ describe('', () => { ); expect(getByTestId('embedSpinner')).toBeInTheDocument(); - await wait(() => !queryByTestId('embedSpinner')); // wait until spinner disappears + await waitFor(() => !queryByTestId('embedSpinner')); // wait until spinner disappears expect(getByTestId('helloWorldEmbeddable')).toBeInTheDocument(); }); }); diff --git a/src/plugins/embeddable/public/lib/embeddables/error_embeddable.test.tsx b/src/plugins/embeddable/public/lib/embeddables/error_embeddable.test.tsx index cb14d7ed11dc9..743db62ced989 100644 --- a/src/plugins/embeddable/public/lib/embeddables/error_embeddable.test.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/error_embeddable.test.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { wait, render } from '@testing-library/react'; +import { waitFor, render } from '@testing-library/react'; import { ErrorEmbeddable } from './error_embeddable'; import { EmbeddableRoot } from './embeddable_root'; @@ -26,7 +26,7 @@ test('ErrorEmbeddable renders an embeddable', async () => { const { getByTestId, getByText } = render(); expect(getByTestId('embeddableStackError')).toBeVisible(); - await wait(() => getByTestId('errorMessageMarkdown')); // wait for lazy markdown component + await waitFor(() => getByTestId('errorMessageMarkdown')); // wait for lazy markdown component expect(getByText(/some error occurred/i)).toBeVisible(); }); @@ -36,7 +36,7 @@ test('ErrorEmbeddable renders an embeddable with markdown message', async () => const { getByTestId, getByText } = render(); expect(getByTestId('embeddableStackError')).toBeVisible(); - await wait(() => getByTestId('errorMessageMarkdown')); // wait for lazy markdown component + await waitFor(() => getByTestId('errorMessageMarkdown')); // wait for lazy markdown component expect(getByText(/some link/i)).toMatchInlineSnapshot(` + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + + } + labelType="label" + > +
+
+ + + +
+
+ +
+ +
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+ +
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +`; + +exports[`CronEditor is rendered with a HOUR frequency 1`] = ` + + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`CronEditor is rendered with a MINUTE frequency 1`] = ` + + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+`; + +exports[`CronEditor is rendered with a MONTH frequency 1`] = ` + + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + } + labelType="label" + > +
+
+ + + +
+
+ +
+ +
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+ +
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`CronEditor is rendered with a WEEK frequency 1`] = ` + + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + } + labelType="label" + > +
+
+ + + +
+
+ +
+ +
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+ +
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`CronEditor is rendered with a YEAR frequency 1`] = ` + + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + } + labelType="label" + > +
+
+ + + +
+
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ + } + labelType="label" + > +
+
+ + + +
+
+ +
+ +
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+ +
+ + +
+ + + +
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/constants.ts b/src/plugins/es_ui_shared/public/components/cron_editor/constants.ts new file mode 100644 index 0000000000000..786e89070d9fb --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/cron_editor/constants.ts @@ -0,0 +1,166 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { padStart } from 'lodash'; +import { EuiSelectOption } from '@elastic/eui'; + +import { DayOrdinal, MonthOrdinal, getOrdinalValue, getDayName, getMonthName } from './services'; +import { Frequency, Field, FieldToValueMap } from './types'; + +type FieldFlags = { + [key in Field]?: boolean; +}; + +function makeSequence(min: number, max: number): number[] { + const values = []; + for (let i = min; i <= max; i++) { + values.push(i); + } + return values; +} + +export const MINUTE_OPTIONS = makeSequence(0, 59).map((value) => ({ + value: value.toString(), + text: padStart(value.toString(), 2, '0'), +})); + +export const HOUR_OPTIONS = makeSequence(0, 23).map((value) => ({ + value: value.toString(), + text: padStart(value.toString(), 2, '0'), +})); + +export const DAY_OPTIONS = makeSequence(1, 7).map((value) => ({ + value: value.toString(), + text: getDayName((value - 1) as DayOrdinal), +})); + +export const DATE_OPTIONS = makeSequence(1, 31).map((value) => ({ + value: value.toString(), + text: getOrdinalValue(value), +})); + +export const MONTH_OPTIONS = makeSequence(1, 12).map((value) => ({ + value: value.toString(), + text: getMonthName((value - 1) as MonthOrdinal), +})); + +export const UNITS: EuiSelectOption[] = [ + { + value: 'MINUTE', + text: 'minute', + }, + { + value: 'HOUR', + text: 'hour', + }, + { + value: 'DAY', + text: 'day', + }, + { + value: 'WEEK', + text: 'week', + }, + { + value: 'MONTH', + text: 'month', + }, + { + value: 'YEAR', + text: 'year', + }, +]; + +export const frequencyToFieldsMap: Record = { + MINUTE: {}, + HOUR: { + minute: true, + }, + DAY: { + hour: true, + minute: true, + }, + WEEK: { + day: true, + hour: true, + minute: true, + }, + MONTH: { + date: true, + hour: true, + minute: true, + }, + YEAR: { + month: true, + date: true, + hour: true, + minute: true, + }, +}; + +export const frequencyToBaselineFieldsMap: Record = { + MINUTE: { + second: '0', + minute: '*', + hour: '*', + date: '*', + month: '*', + day: '?', + }, + HOUR: { + second: '0', + minute: '0', + hour: '*', + date: '*', + month: '*', + day: '?', + }, + DAY: { + second: '0', + minute: '0', + hour: '0', + date: '*', + month: '*', + day: '?', + }, + WEEK: { + second: '0', + minute: '0', + hour: '0', + date: '?', + month: '*', + day: '7', + }, + MONTH: { + second: '0', + minute: '0', + hour: '0', + date: '1', + month: '*', + day: '?', + }, + YEAR: { + second: '0', + minute: '0', + hour: '0', + date: '1', + month: '1', + day: '?', + }, +}; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_daily.js b/src/plugins/es_ui_shared/public/components/cron_editor/cron_daily.tsx similarity index 83% rename from src/plugins/es_ui_shared/public/components/cron_editor/cron_daily.js rename to src/plugins/es_ui_shared/public/components/cron_editor/cron_daily.tsx index f038766766fe0..42fce194945b9 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/cron_daily.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_daily.tsx @@ -18,13 +18,25 @@ */ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +interface Props { + minute?: string; + minuteOptions: EuiSelectOption[]; + hour?: string; + hourOptions: EuiSelectOption[]; + onChange: ({ minute, hour }: { minute?: string; hour?: string }) => void; +} -export const CronDaily = ({ minute, minuteOptions, hour, hourOptions, onChange }) => ( +export const CronDaily: React.FunctionComponent = ({ + minute, + minuteOptions, + hour, + hourOptions, + onChange, +}) => ( ); - -CronDaily.propTypes = { - minute: PropTypes.string.isRequired, - minuteOptions: PropTypes.array.isRequired, - hour: PropTypes.string.isRequired, - hourOptions: PropTypes.array.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx b/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx new file mode 100644 index 0000000000000..8d0d497e8b5d4 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.test.tsx @@ -0,0 +1,137 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import sinon from 'sinon'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { mountWithI18nProvider } from '@kbn/test/jest'; + +import { Frequency } from './types'; +import { CronEditor } from './cron_editor'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { + return { + htmlIdGenerator: () => () => `generated-id`, + }; +}); + +describe('CronEditor', () => { + ['MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'].forEach((unit) => { + test(`is rendered with a ${unit} frequency`, () => { + const component = mountWithI18nProvider( + {}} + /> + ); + + expect(component).toMatchSnapshot(); + }); + }); + + describe('props', () => { + describe('frequencyBlockList', () => { + it('excludes the blocked frequencies from the frequency list', () => { + const component = mountWithI18nProvider( + {}} + /> + ); + + const frequencySelect = findTestSubject(component, 'cronFrequencySelect'); + expect(frequencySelect.text()).toBe('minutedaymonth'); + }); + }); + + describe('cronExpression', () => { + it('sets the values of the fields', () => { + const component = mountWithI18nProvider( + {}} + /> + ); + + const monthSelect = findTestSubject(component, 'cronFrequencyYearlyMonthSelect'); + expect(monthSelect.props().value).toBe('2'); + + const dateSelect = findTestSubject(component, 'cronFrequencyYearlyDateSelect'); + expect(dateSelect.props().value).toBe('5'); + + const hourSelect = findTestSubject(component, 'cronFrequencyYearlyHourSelect'); + expect(hourSelect.props().value).toBe('10'); + + const minuteSelect = findTestSubject(component, 'cronFrequencyYearlyMinuteSelect'); + expect(minuteSelect.props().value).toBe('20'); + }); + }); + + describe('onChange', () => { + it('is called when the frequency changes', () => { + const onChangeSpy = sinon.spy(); + const component = mountWithI18nProvider( + + ); + + const frequencySelect = findTestSubject(component, 'cronFrequencySelect'); + frequencySelect.simulate('change', { target: { value: 'MONTH' } }); + + sinon.assert.calledWith(onChangeSpy, { + cronExpression: '0 0 0 1 * ?', + fieldToPreferredValueMap: {}, + frequency: 'MONTH', + }); + }); + + it(`is called when a field's value changes`, () => { + const onChangeSpy = sinon.spy(); + const component = mountWithI18nProvider( + + ); + + const minuteSelect = findTestSubject(component, 'cronFrequencyYearlyMinuteSelect'); + minuteSelect.simulate('change', { target: { value: '40' } }); + + sinon.assert.calledWith(onChangeSpy, { + cronExpression: '0 40 * * * ?', + fieldToPreferredValueMap: { minute: '40' }, + frequency: 'YEAR', + }); + }); + }); + }); +}); diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.js b/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.tsx similarity index 58% rename from src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.js rename to src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.tsx index cde2a253d7630..72e2f51c37e4c 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_editor.tsx @@ -18,207 +18,86 @@ */ import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { padStart } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EuiSelect, EuiFormRow, EuiSelectOption } from '@elastic/eui'; -import { EuiSelect, EuiFormRow } from '@elastic/eui'; +import { Frequency, Field, FieldToValueMap } from './types'; import { - getOrdinalValue, - getDayName, - getMonthName, - cronExpressionToParts, - cronPartsToExpression, - MINUTE, - HOUR, - DAY, - WEEK, - MONTH, - YEAR, -} from './services'; - + MINUTE_OPTIONS, + HOUR_OPTIONS, + DAY_OPTIONS, + DATE_OPTIONS, + MONTH_OPTIONS, + UNITS, + frequencyToFieldsMap, + frequencyToBaselineFieldsMap, +} from './constants'; + +import { cronExpressionToParts, cronPartsToExpression } from './services'; import { CronHourly } from './cron_hourly'; import { CronDaily } from './cron_daily'; import { CronWeekly } from './cron_weekly'; import { CronMonthly } from './cron_monthly'; import { CronYearly } from './cron_yearly'; -function makeSequence(min, max) { - const values = []; - for (let i = min; i <= max; i++) { - values.push(i); +const excludeBlockListedFrequencies = ( + units: EuiSelectOption[], + blockListedUnits: string[] = [] +): EuiSelectOption[] => { + if (blockListedUnits.length === 0) { + return units; } - return values; -} -const MINUTE_OPTIONS = makeSequence(0, 59).map((value) => ({ - value: value.toString(), - text: padStart(value, 2, '0'), -})); - -const HOUR_OPTIONS = makeSequence(0, 23).map((value) => ({ - value: value.toString(), - text: padStart(value, 2, '0'), -})); - -const DAY_OPTIONS = makeSequence(1, 7).map((value) => ({ - value: value.toString(), - text: getDayName(value - 1), -})); - -const DATE_OPTIONS = makeSequence(1, 31).map((value) => ({ - value: value.toString(), - text: getOrdinalValue(value), -})); - -const MONTH_OPTIONS = makeSequence(1, 12).map((value) => ({ - value: value.toString(), - text: getMonthName(value - 1), -})); - -const UNITS = [ - { - value: MINUTE, - text: 'minute', - }, - { - value: HOUR, - text: 'hour', - }, - { - value: DAY, - text: 'day', - }, - { - value: WEEK, - text: 'week', - }, - { - value: MONTH, - text: 'month', - }, - { - value: YEAR, - text: 'year', - }, -]; - -const frequencyToFieldsMap = { - [MINUTE]: {}, - [HOUR]: { - minute: true, - }, - [DAY]: { - hour: true, - minute: true, - }, - [WEEK]: { - day: true, - hour: true, - minute: true, - }, - [MONTH]: { - date: true, - hour: true, - minute: true, - }, - [YEAR]: { - month: true, - date: true, - hour: true, - minute: true, - }, + return units.filter(({ value }) => !blockListedUnits.includes(value as string)); }; -const frequencyToBaselineFieldsMap = { - [MINUTE]: { - second: '0', - minute: '*', - hour: '*', - date: '*', - month: '*', - day: '?', - }, - [HOUR]: { - second: '0', - minute: '0', - hour: '*', - date: '*', - month: '*', - day: '?', - }, - [DAY]: { - second: '0', - minute: '0', - hour: '0', - date: '*', - month: '*', - day: '?', - }, - [WEEK]: { - second: '0', - minute: '0', - hour: '0', - date: '?', - month: '*', - day: '7', - }, - [MONTH]: { - second: '0', - minute: '0', - hour: '0', - date: '1', - month: '*', - day: '?', - }, - [YEAR]: { - second: '0', - minute: '0', - hour: '0', - date: '1', - month: '1', - day: '?', - }, -}; +interface Props { + frequencyBlockList?: string[]; + fieldToPreferredValueMap: FieldToValueMap; + frequency: Frequency; + cronExpression: string; + onChange: ({ + cronExpression, + fieldToPreferredValueMap, + frequency, + }: { + cronExpression: string; + fieldToPreferredValueMap: FieldToValueMap; + frequency: Frequency; + }) => void; +} -export class CronEditor extends Component { - static propTypes = { - fieldToPreferredValueMap: PropTypes.object.isRequired, - frequency: PropTypes.string.isRequired, - cronExpression: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - }; +type State = FieldToValueMap; - static getDerivedStateFromProps(props) { +export class CronEditor extends Component { + static getDerivedStateFromProps(props: Props) { const { cronExpression } = props; return cronExpressionToParts(cronExpression); } - constructor(props) { + constructor(props: Props) { super(props); const { cronExpression } = props; - const parsedCron = cronExpressionToParts(cronExpression); - this.state = { ...parsedCron, }; } - onChangeFrequency = (frequency) => { + onChangeFrequency = (frequency: Frequency) => { const { onChange, fieldToPreferredValueMap } = this.props; // Update fields which aren't editable with acceptable baseline values. - const editableFields = Object.keys(frequencyToFieldsMap[frequency]); - const inheritedFields = editableFields.reduce( - (baselineFields, field) => { + const editableFields = Object.keys(frequencyToFieldsMap[frequency]) as Field[]; + const inheritedFields = editableFields.reduce( + (fieldBaselines, field) => { if (fieldToPreferredValueMap[field] != null) { - baselineFields[field] = fieldToPreferredValueMap[field]; + fieldBaselines[field] = fieldToPreferredValueMap[field]; } - return baselineFields; + return fieldBaselines; }, { ...frequencyToBaselineFieldsMap[frequency] } ); @@ -232,18 +111,21 @@ export class CronEditor extends Component { }); }; - onChangeFields = (fields) => { + onChangeFields = (fields: FieldToValueMap) => { const { onChange, frequency, fieldToPreferredValueMap } = this.props; - const editableFields = Object.keys(frequencyToFieldsMap[frequency]); - const newFieldToPreferredValueMap = {}; + const editableFields = Object.keys(frequencyToFieldsMap[frequency]) as Field[]; + const newFieldToPreferredValueMap: FieldToValueMap = {}; - const editedFields = editableFields.reduce( + const editedFields = editableFields.reduce( (accumFields, field) => { if (fields[field] !== undefined) { accumFields[field] = fields[field]; - // Once the user touches a field, we want to persist its value as the user changes - // the cron frequency. + // If the user changes a field's value, we want to maintain that value in the relevant + // field, even as the frequency field changes. For example, if the user selects "Monthly" + // frequency and changes the "Hour" field to "10", that field should still say "10" if the + // user changes the frequency to "Weekly". We'll support this UX by storing these values + // in the fieldToPreferredValueMap. newFieldToPreferredValueMap[field] = fields[field]; } else { accumFields[field] = this.state[field]; @@ -271,10 +153,10 @@ export class CronEditor extends Component { const { minute, hour, day, date, month } = this.state; switch (frequency) { - case MINUTE: + case 'MINUTE': return; - case HOUR: + case 'HOUR': return ( ); - case DAY: + case 'DAY': return ( ); - case WEEK: + case 'WEEK': return ( ); - case MONTH: + case 'MONTH': return ( ); - case YEAR: + case 'YEAR': return ( @@ -352,9 +234,11 @@ export class CronEditor extends Component { fullWidth > this.onChangeFrequency(e.target.value)} + onChange={(e: React.ChangeEvent) => + this.onChangeFrequency(e.target.value as Frequency) + } fullWidth prepend={i18n.translate('esUi.cronEditor.textEveryLabel', { defaultMessage: 'Every', diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_hourly.js b/src/plugins/es_ui_shared/public/components/cron_editor/cron_hourly.tsx similarity index 83% rename from src/plugins/es_ui_shared/public/components/cron_editor/cron_hourly.js rename to src/plugins/es_ui_shared/public/components/cron_editor/cron_hourly.tsx index a04e83195b97f..fb793fd4ff605 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/cron_hourly.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_hourly.tsx @@ -18,13 +18,17 @@ */ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; -import { EuiFormRow, EuiSelect } from '@elastic/eui'; +interface Props { + minute?: string; + minuteOptions: EuiSelectOption[]; + onChange: ({ minute }: { minute?: string }) => void; +} -export const CronHourly = ({ minute, minuteOptions, onChange }) => ( +export const CronHourly: React.FunctionComponent = ({ minute, minuteOptions, onChange }) => ( ( ); - -CronHourly.propTypes = { - minute: PropTypes.string.isRequired, - minuteOptions: PropTypes.array.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_monthly.js b/src/plugins/es_ui_shared/public/components/cron_editor/cron_monthly.tsx similarity index 86% rename from src/plugins/es_ui_shared/public/components/cron_editor/cron_monthly.js rename to src/plugins/es_ui_shared/public/components/cron_editor/cron_monthly.tsx index 28057bd7d9293..729ef1f5f0c15 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/cron_monthly.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_monthly.tsx @@ -18,13 +18,21 @@ */ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +interface Props { + minute?: string; + minuteOptions: EuiSelectOption[]; + hour?: string; + hourOptions: EuiSelectOption[]; + date?: string; + dateOptions: EuiSelectOption[]; + onChange: ({ minute, hour, date }: { minute?: string; hour?: string; date?: string }) => void; +} -export const CronMonthly = ({ +export const CronMonthly: React.FunctionComponent = ({ minute, minuteOptions, hour, @@ -94,13 +102,3 @@ export const CronMonthly = ({ ); - -CronMonthly.propTypes = { - minute: PropTypes.string.isRequired, - minuteOptions: PropTypes.array.isRequired, - hour: PropTypes.string.isRequired, - hourOptions: PropTypes.array.isRequired, - date: PropTypes.string.isRequired, - dateOptions: PropTypes.array.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_weekly.js b/src/plugins/es_ui_shared/public/components/cron_editor/cron_weekly.tsx similarity index 86% rename from src/plugins/es_ui_shared/public/components/cron_editor/cron_weekly.js rename to src/plugins/es_ui_shared/public/components/cron_editor/cron_weekly.tsx index c06eecbb381b3..1f10ba5a4ab84 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/cron_weekly.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_weekly.tsx @@ -18,13 +18,21 @@ */ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +interface Props { + minute?: string; + minuteOptions: EuiSelectOption[]; + hour?: string; + hourOptions: EuiSelectOption[]; + day?: string; + dayOptions: EuiSelectOption[]; + onChange: ({ minute, hour, day }: { minute?: string; hour?: string; day?: string }) => void; +} -export const CronWeekly = ({ +export const CronWeekly: React.FunctionComponent = ({ minute, minuteOptions, hour, @@ -94,13 +102,3 @@ export const CronWeekly = ({ ); - -CronWeekly.propTypes = { - minute: PropTypes.string.isRequired, - minuteOptions: PropTypes.array.isRequired, - hour: PropTypes.string.isRequired, - hourOptions: PropTypes.array.isRequired, - day: PropTypes.string.isRequired, - dayOptions: PropTypes.array.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/cron_yearly.js b/src/plugins/es_ui_shared/public/components/cron_editor/cron_yearly.tsx similarity index 86% rename from src/plugins/es_ui_shared/public/components/cron_editor/cron_yearly.js rename to src/plugins/es_ui_shared/public/components/cron_editor/cron_yearly.tsx index c3b9691750937..8b65a6f77cfc0 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/cron_yearly.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/cron_yearly.tsx @@ -18,13 +18,34 @@ */ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; -export const CronYearly = ({ +interface Props { + minute?: string; + minuteOptions: EuiSelectOption[]; + hour?: string; + hourOptions: EuiSelectOption[]; + date?: string; + dateOptions: EuiSelectOption[]; + month?: string; + monthOptions: EuiSelectOption[]; + onChange: ({ + minute, + hour, + date, + month, + }: { + minute?: string; + hour?: string; + date?: string; + month?: string; + }) => void; +} + +export const CronYearly: React.FunctionComponent = ({ minute, minuteOptions, hour, @@ -115,15 +136,3 @@ export const CronYearly = ({ ); - -CronYearly.propTypes = { - minute: PropTypes.string.isRequired, - minuteOptions: PropTypes.array.isRequired, - hour: PropTypes.string.isRequired, - hourOptions: PropTypes.array.isRequired, - date: PropTypes.string.isRequired, - dateOptions: PropTypes.array.isRequired, - month: PropTypes.string.isRequired, - monthOptions: PropTypes.array.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/index.d.ts b/src/plugins/es_ui_shared/public/components/cron_editor/index.d.ts deleted file mode 100644 index b318587057c76..0000000000000 --- a/src/plugins/es_ui_shared/public/components/cron_editor/index.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export declare const MINUTE: string; -export declare const HOUR: string; -export declare const DAY: string; -export declare const WEEK: string; -export declare const MONTH: string; -export declare const YEAR: string; -export declare const CronEditor: any; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/index.js b/src/plugins/es_ui_shared/public/components/cron_editor/index.ts similarity index 92% rename from src/plugins/es_ui_shared/public/components/cron_editor/index.js rename to src/plugins/es_ui_shared/public/components/cron_editor/index.ts index 6c4539a6c3f75..b1e27feb6f835 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/index.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/index.ts @@ -17,5 +17,5 @@ * under the License. */ +export { Frequency } from './types'; export { CronEditor } from './cron_editor'; -export { MINUTE, HOUR, DAY, WEEK, MONTH, YEAR } from './services'; diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/services/cron.js b/src/plugins/es_ui_shared/public/components/cron_editor/services/cron.ts similarity index 81% rename from src/plugins/es_ui_shared/public/components/cron_editor/services/cron.js rename to src/plugins/es_ui_shared/public/components/cron_editor/services/cron.ts index 995169739f7dc..be78552584148 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/services/cron.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/services/cron.ts @@ -17,15 +17,10 @@ * under the License. */ -export const MINUTE = 'MINUTE'; -export const HOUR = 'HOUR'; -export const DAY = 'DAY'; -export const WEEK = 'WEEK'; -export const MONTH = 'MONTH'; -export const YEAR = 'YEAR'; +import { FieldToValueMap } from '../types'; -export function cronExpressionToParts(expression) { - const parsedCron = { +export function cronExpressionToParts(expression: string): FieldToValueMap { + const parsedCron: FieldToValueMap = { second: undefined, minute: undefined, hour: undefined, @@ -63,6 +58,13 @@ export function cronExpressionToParts(expression) { return parsedCron; } -export function cronPartsToExpression({ second, minute, hour, day, date, month }) { +export function cronPartsToExpression({ + second, + minute, + hour, + day, + date, + month, +}: FieldToValueMap): string { return `${second} ${minute} ${hour} ${date} ${month} ${day}`; } diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/services/humanized_numbers.js b/src/plugins/es_ui_shared/public/components/cron_editor/services/humanized_numbers.ts similarity index 87% rename from src/plugins/es_ui_shared/public/components/cron_editor/services/humanized_numbers.js rename to src/plugins/es_ui_shared/public/components/cron_editor/services/humanized_numbers.ts index 69fa085cc3f3e..25ac0db3d35d8 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/services/humanized_numbers.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/services/humanized_numbers.ts @@ -19,6 +19,9 @@ import { i18n } from '@kbn/i18n'; +export type DayOrdinal = 0 | 1 | 2 | 3 | 4 | 5 | 6; +export type MonthOrdinal = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; + // The international ISO standard dictates Monday as the first day of the week, but cron patterns // use Sunday as the first day, so we're going with the cron way. const dayOrdinalToDayNameMap = { @@ -46,7 +49,7 @@ const monthOrdinalToMonthNameMap = { 11: i18n.translate('esUi.cronEditor.month.december', { defaultMessage: 'December' }), }; -export function getOrdinalValue(number) { +export function getOrdinalValue(number: number): string { // TODO: This is breaking reporting pdf generation. Possibly due to phantom not setting locale, // which is needed by i18n (formatjs). Need to verify, fix, and restore i18n in place of static stings. // return i18n.translate('esUi.cronEditor.number.ordinal', { @@ -57,15 +60,16 @@ export function getOrdinalValue(number) { // Protects against falsey (including 0) values const num = number && number.toString(); - let lastDigit = num && num.substr(-1); + const lastDigitString = num && num.substr(-1); let ordinal; - if (!lastDigit) { - return number; + if (!lastDigitString) { + return number.toString(); } - lastDigit = parseFloat(lastDigit); - switch (lastDigit) { + const lastDigitNumeric = parseFloat(lastDigitString); + + switch (lastDigitNumeric) { case 1: ordinal = 'st'; break; @@ -82,10 +86,10 @@ export function getOrdinalValue(number) { return `${num}${ordinal}`; } -export function getDayName(dayOrdinal) { +export function getDayName(dayOrdinal: DayOrdinal): string { return dayOrdinalToDayNameMap[dayOrdinal]; } -export function getMonthName(monthOrdinal) { +export function getMonthName(monthOrdinal: MonthOrdinal): string { return monthOrdinalToMonthNameMap[monthOrdinal]; } diff --git a/src/plugins/es_ui_shared/public/components/cron_editor/services/index.js b/src/plugins/es_ui_shared/public/components/cron_editor/services/index.ts similarity index 80% rename from src/plugins/es_ui_shared/public/components/cron_editor/services/index.js rename to src/plugins/es_ui_shared/public/components/cron_editor/services/index.ts index cb4af15bf1945..ff10a283c2fa1 100644 --- a/src/plugins/es_ui_shared/public/components/cron_editor/services/index.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/services/index.ts @@ -17,5 +17,11 @@ * under the License. */ -export * from './cron'; -export * from './humanized_numbers'; +export { cronExpressionToParts, cronPartsToExpression } from './cron'; +export { + getOrdinalValue, + getDayName, + getMonthName, + DayOrdinal, + MonthOrdinal, +} from './humanized_numbers'; diff --git a/tasks/config/watch.js b/src/plugins/es_ui_shared/public/components/cron_editor/types.ts similarity index 78% rename from tasks/config/watch.js rename to src/plugins/es_ui_shared/public/components/cron_editor/types.ts index b132b7e5f8087..3e5b7c916632a 100644 --- a/tasks/config/watch.js +++ b/src/plugins/es_ui_shared/public/components/cron_editor/types.ts @@ -17,9 +17,8 @@ * under the License. */ -module.exports = { - peg: { - files: ['src/legacy/utils/kuery/ast/*.peg'], - tasks: ['peg'], - }, +export type Frequency = 'MINUTE' | 'HOUR' | 'DAY' | 'WEEK' | 'MONTH' | 'YEAR'; +export type Field = 'second' | 'minute' | 'hour' | 'day' | 'date' | 'month'; +export type FieldToValueMap = { + [key in Field]?: string; }; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index f48198459d48d..304916b1d379d 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -30,7 +30,7 @@ export { JsonEditor, OnJsonEditorUpdateHandler, JsonEditorState } from './compon export { SectionLoading } from './components/section_loading'; -export { CronEditor, MINUTE, HOUR, DAY, WEEK, MONTH, YEAR } from './components/cron_editor'; +export { Frequency, CronEditor } from './components/cron_editor'; export { SendRequestConfig, diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.ts b/src/plugins/es_ui_shared/public/request/use_request.test.ts index 2a639f93b47b4..822bf56e5e3cc 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.test.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.test.ts @@ -101,8 +101,9 @@ describe('useRequest hook', () => { const { setupSuccessRequest, completeRequest, hookResult } = helpers; setupSuccessRequest(); expect(hookResult.isInitialRequest).toBe(true); - - hookResult.resendRequest(); + act(() => { + hookResult.resendRequest(); + }); await completeRequest(); expect(hookResult.isInitialRequest).toBe(false); }); 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 29cbec38a5982..d24b31599f903 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 @@ -54,7 +54,7 @@ exports[`FieldEditor should render create new scripted field correctly 1`] = ` @@ -294,7 +294,7 @@ exports[`FieldEditor should render edit scripted field correctly 1`] = ` @@ -586,7 +586,7 @@ exports[`FieldEditor should show conflict field warning 1`] = ` @@ -827,7 +827,7 @@ exports[`FieldEditor should show deprecated lang warning 1`] = ` @@ -1200,7 +1200,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 29a87a65fdff7..a402dc59185e8 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 @@ -425,7 +425,7 @@ export class FieldEditor extends PureComponent } > diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index 87a3fd8f5b499..1e66a9baa812e 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -18,10 +18,10 @@ */ import { ITagsClient } from '../common'; -import { SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent } from './api'; +import { SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent, ITagsCache } from './api'; -const createClientMock = (): jest.Mocked => { - const mock = { +const createClientMock = () => { + const mock: jest.Mocked = { create: jest.fn(), get: jest.fn(), getAll: jest.fn(), @@ -32,14 +32,25 @@ const createClientMock = (): jest.Mocked => { return mock; }; +const createCacheMock = () => { + const mock: jest.Mocked = { + getState: jest.fn(), + getState$: jest.fn(), + }; + + return mock; +}; + interface SavedObjectsTaggingApiMock { client: jest.Mocked; + cache: jest.Mocked; ui: SavedObjectsTaggingApiUiMock; } const createApiMock = (): SavedObjectsTaggingApiMock => { - const mock = { + const mock: SavedObjectsTaggingApiMock = { client: createClientMock(), + cache: createCacheMock(), ui: createApiUiMock(), }; @@ -50,8 +61,8 @@ type SavedObjectsTaggingApiUiMock = Omit, components: SavedObjectsTaggingApiUiComponentMock; }; -const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { - const mock = { +const createApiUiMock = () => { + const mock: SavedObjectsTaggingApiUiMock = { components: createApiUiComponentsMock(), // TS is very picky with type guards hasTagDecoration: jest.fn() as any, @@ -69,8 +80,8 @@ const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { type SavedObjectsTaggingApiUiComponentMock = jest.Mocked; -const createApiUiComponentsMock = (): SavedObjectsTaggingApiUiComponentMock => { - const mock = { +const createApiUiComponentsMock = () => { + const mock: SavedObjectsTaggingApiUiComponentMock = { TagList: jest.fn(), TagSelector: jest.fn(), SavedObjectSaveModalTagSelector: jest.fn(), @@ -82,6 +93,7 @@ const createApiUiComponentsMock = (): SavedObjectsTaggingApiUiComponentMock => { export const taggingApiMock = { create: createApiMock, createClient: createClientMock, + createCache: createCacheMock, createUi: createApiUiMock, createComponents: createApiUiComponentsMock, }; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 81f7cc9326a77..987930af1e3e4 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -17,22 +17,49 @@ * under the License. */ +import { Observable } from 'rxjs'; import { SearchFilterConfig, EuiTableFieldDataColumnType } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import { SavedObject, SavedObjectReference } from '../../../core/types'; import { SavedObjectsFindOptionsReference } from '../../../core/public'; import { SavedObject as SavedObjectClass } from '../../saved_objects/public'; import { TagDecoratedSavedObject } from './decorator'; -import { ITagsClient } from '../common'; +import { ITagsClient, Tag } from '../common'; /** * @public */ export interface SavedObjectsTaggingApi { + /** + * The client to perform tag-related operations on the server-side + */ client: ITagsClient; + /** + * A client-side auto-refreshing cache of the existing tags. Can be used + * to synchronously access the list of tags. + */ + cache: ITagsCache; + /** + * UI API to use to add tagging capabilities to an application + */ ui: SavedObjectsTaggingApiUi; } +/** + * @public + */ +export interface ITagsCache { + /** + * Return the current state of the cache + */ + getState(): Tag[]; + + /** + * Return an observable that will emit everytime the cache's state mutates. + */ + getState$(): Observable; +} + /** * @public */ diff --git a/src/plugins/saved_objects_tagging_oss/public/index.ts b/src/plugins/saved_objects_tagging_oss/public/index.ts index bc824621830d2..ef3087f944add 100644 --- a/src/plugins/saved_objects_tagging_oss/public/index.ts +++ b/src/plugins/saved_objects_tagging_oss/public/index.ts @@ -26,6 +26,7 @@ export { SavedObjectsTaggingApi, SavedObjectsTaggingApiUi, SavedObjectsTaggingApiUiComponent, + ITagsCache, TagListComponentProps, TagSelectorComponentProps, GetSearchBarFilterOptions, diff --git a/src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.tsx b/src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.tsx index 402fc55eaf7c3..bd78cdc931d0a 100644 --- a/src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.tsx +++ b/src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.tsx @@ -49,6 +49,8 @@ interface State { * React component for displaying the example data associated with the Telemetry opt-in banner. */ export class OptInExampleFlyout extends React.PureComponent { + _isMounted = false; + public readonly state: State = { data: null, isLoading: true, @@ -56,14 +58,18 @@ export class OptInExampleFlyout extends React.PureComponent { }; async componentDidMount() { + this._isMounted = true; + try { const { fetchExample } = this.props; const clusters = await fetchExample(); - this.setState({ - data: Array.isArray(clusters) ? clusters : null, - isLoading: false, - hasPrivilegeToRead: true, - }); + if (this._isMounted) { + this.setState({ + data: Array.isArray(clusters) ? clusters : null, + isLoading: false, + hasPrivilegeToRead: true, + }); + } } catch (err) { this.setState({ isLoading: false, @@ -72,6 +78,10 @@ export class OptInExampleFlyout extends React.PureComponent { } } + componentWillUnmount() { + this._isMounted = false; + } + renderBody({ data, isLoading, hasPrivilegeToRead }: State) { if (isLoading) { return loadingSpinner; diff --git a/src/plugins/vis_default_editor/public/components/agg_select.tsx b/src/plugins/vis_default_editor/public/components/agg_select.tsx index 9d45b72d35cc0..689cc52691bb6 100644 --- a/src/plugins/vis_default_editor/public/components/agg_select.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_select.tsx @@ -77,15 +77,15 @@ function DefaultEditorAggSelect({ } const helpLink = value && aggHelpLink && ( - - + + - - + + ); const errors = aggError ? [aggError] : []; diff --git a/src/plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx b/src/plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx index 7bc8cdbd14170..e9494e086a734 100644 --- a/src/plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx +++ b/src/plugins/vis_type_markdown/public/markdown_vis_controller.test.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { wait, render } from '@testing-library/react'; +import { waitFor, render } from '@testing-library/react'; import MarkdownVisComponent from './markdown_vis_controller'; describe('markdown vis controller', () => { @@ -36,7 +36,7 @@ describe('markdown vis controller', () => { ); - await wait(() => getByTestId('markdownBody')); + await waitFor(() => getByTestId('markdownBody')); expect(getByText('markdown')).toMatchInlineSnapshot(`
{ ); - await wait(() => getByTestId('markdownBody')); + await waitFor(() => getByTestId('markdownBody')); expect(getByText(/testing/i)).toMatchInlineSnapshot(`

@@ -82,7 +82,7 @@ describe('markdown vis controller', () => { ); - await wait(() => getByTestId('markdownBody')); + await waitFor(() => getByTestId('markdownBody')); expect(getByText(/initial/i)).toBeInTheDocument(); @@ -112,7 +112,7 @@ describe('markdown vis controller', () => { ); - await wait(() => getByTestId('markdownBody')); + await waitFor(() => getByTestId('markdownBody')); expect(renderComplete).toHaveBeenCalledTimes(1); }); @@ -122,7 +122,7 @@ describe('markdown vis controller', () => { ); - await wait(() => getByTestId('markdownBody')); + await waitFor(() => getByTestId('markdownBody')); expect(renderComplete).toHaveBeenCalledTimes(1); @@ -139,7 +139,7 @@ describe('markdown vis controller', () => { ); - await wait(() => getByTestId('markdownBody')); + await waitFor(() => getByTestId('markdownBody')); expect(renderComplete).toHaveBeenCalledTimes(1); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js index 8a073ca32b94a..e4c4c1df202ef 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.test.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -17,6 +17,8 @@ * under the License. */ +import 'jest-canvas-mock'; + import $ from 'jquery'; import 'leaflet/dist/leaflet.js'; diff --git a/src/plugins/vis_type_vislib/public/vislib/components/legend/legend.test.tsx b/src/plugins/vis_type_vislib/public/vislib/components/legend/legend.test.tsx index a3fb536d0aec5..7acc97404c11c 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/legend/legend.test.tsx +++ b/src/plugins/vis_type_vislib/public/vislib/components/legend/legend.test.tsx @@ -25,6 +25,7 @@ import { EuiButtonGroup } from '@elastic/eui'; import { VisLegend, VisLegendProps } from './legend'; import { legendColors } from './models'; +import { act } from '@testing-library/react'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -206,7 +207,9 @@ describe('VisLegend Component', () => { const first = getLegendItems(wrapper).first(); first.simulate('click'); const filterGroup = wrapper.find(EuiButtonGroup).first(); - filterGroup.getElement().props.onChange('filterIn'); + act(() => { + filterGroup.getElement().props.onChange('filterIn'); + }); expect(fireEvent).toHaveBeenCalledWith({ name: 'filterBucket', @@ -219,7 +222,9 @@ describe('VisLegend Component', () => { const first = getLegendItems(wrapper).first(); first.simulate('click'); const filterGroup = wrapper.find(EuiButtonGroup).first(); - filterGroup.getElement().props.onChange('filterOut'); + act(() => { + filterGroup.getElement().props.onChange('filterOut'); + }); expect(fireEvent).toHaveBeenCalledWith({ name: 'filterBucket', diff --git a/tasks/config/run.js b/tasks/config/run.js deleted file mode 100644 index 0a1bb9617e1f9..0000000000000 --- a/tasks/config/run.js +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -const { version } = require('../../package.json'); -const KIBANA_INSTALL_DIR = - process.env.KIBANA_INSTALL_DIR || - `./build/oss/kibana-${version}-SNAPSHOT-${process.platform}-x86_64`; - -module.exports = function () { - const NODE = 'node'; - const YARN = 'yarn'; - const scriptWithGithubChecks = ({ title, options, cmd, args }) => - process.env.CHECKS_REPORTER_ACTIVE === 'true' - ? { - options, - cmd: YARN, - args: ['run', 'github-checks-reporter', title, cmd, ...args], - } - : { options, cmd, args }; - const gruntTaskWithGithubChecks = (title, task) => - scriptWithGithubChecks({ - title, - cmd: YARN, - args: ['run', 'grunt', task], - }); - - return { - // used by the test and jenkins:unit tasks - // runs the eslint script to check for linting errors - eslint: scriptWithGithubChecks({ - title: 'eslint', - cmd: NODE, - args: ['scripts/eslint', '--no-cache'], - }), - - sasslint: scriptWithGithubChecks({ - title: 'sasslint', - cmd: NODE, - args: ['scripts/sasslint'], - }), - - // used by the test tasks - // runs the check_file_casing script to ensure filenames use correct casing - checkFileCasing: scriptWithGithubChecks({ - title: 'Check file casing', - cmd: NODE, - args: [ - 'scripts/check_file_casing', - '--quiet', // only log errors, not warnings - ], - }), - - // used by the test tasks - // runs the check_published_api_changes script to ensure API changes are explictily accepted - checkDocApiChanges: scriptWithGithubChecks({ - title: 'Check core API changes', - cmd: NODE, - args: ['scripts/check_published_api_changes'], - }), - - // used by the test and jenkins:unit tasks - // runs the typecheck script to check for Typescript type errors - typeCheck: scriptWithGithubChecks({ - title: 'Type check', - cmd: NODE, - args: ['scripts/type_check'], - }), - - // used by the test and jenkins:unit tasks - // ensures that all typescript files belong to a typescript project - checkTsProjects: scriptWithGithubChecks({ - title: 'TypeScript - all files belong to a TypeScript project', - cmd: NODE, - args: ['scripts/check_ts_projects'], - }), - - // used by the test and jenkins:unit tasks - // runs the i18n_check script to check i18n engine usage - i18nCheck: scriptWithGithubChecks({ - title: 'Internationalization check', - cmd: NODE, - args: ['scripts/i18n_check', '--ignore-missing'], - }), - - telemetryCheck: scriptWithGithubChecks({ - title: 'Telemetry Schema check', - cmd: NODE, - args: ['scripts/telemetry_check'], - }), - - // used by the test:quick task - // runs all node.js/server mocha tests - mocha: scriptWithGithubChecks({ - title: 'Mocha tests', - cmd: NODE, - args: ['scripts/mocha'], - }), - - // used by the test:mochaCoverage task - mochaCoverage: scriptWithGithubChecks({ - title: 'Mocha tests coverage', - cmd: YARN, - args: [ - 'nyc', - '--reporter=html', - '--reporter=json-summary', - '--report-dir=./target/kibana-coverage/mocha', - NODE, - 'scripts/mocha', - ], - }), - - verifyNotice: scriptWithGithubChecks({ - title: 'Verify NOTICE.txt', - options: { - wait: true, - }, - cmd: NODE, - args: ['scripts/notice', '--validate'], - }), - - test_hardening: scriptWithGithubChecks({ - title: 'Node.js hardening tests', - cmd: NODE, - args: ['scripts/test_hardening.js'], - }), - - apiIntegrationTests: scriptWithGithubChecks({ - title: 'API integration tests', - cmd: NODE, - args: [ - 'scripts/functional_tests', - '--config', - 'test/api_integration/config.js', - '--bail', - '--debug', - ], - }), - - serverIntegrationTests: scriptWithGithubChecks({ - title: 'Server integration tests', - cmd: NODE, - args: [ - 'scripts/functional_tests', - '--config', - 'test/server_integration/http/ssl/config.js', - '--config', - 'test/server_integration/http/ssl_redirect/config.js', - '--config', - 'test/server_integration/http/platform/config.ts', - '--config', - 'test/server_integration/http/ssl_with_p12/config.js', - '--config', - 'test/server_integration/http/ssl_with_p12_intermediate/config.js', - '--bail', - '--debug', - '--kibana-install-dir', - KIBANA_INSTALL_DIR, - ], - }), - - interpreterFunctionalTestsRelease: scriptWithGithubChecks({ - title: 'Interpreter functional tests', - cmd: NODE, - args: [ - 'scripts/functional_tests', - '--config', - 'test/interpreter_functional/config.ts', - '--bail', - '--debug', - '--kibana-install-dir', - KIBANA_INSTALL_DIR, - ], - }), - - pluginFunctionalTestsRelease: scriptWithGithubChecks({ - title: 'Plugin functional tests', - cmd: NODE, - args: [ - 'scripts/functional_tests', - '--config', - 'test/plugin_functional/config.ts', - '--bail', - '--debug', - ], - }), - - exampleFunctionalTestsRelease: scriptWithGithubChecks({ - title: 'Example functional tests', - cmd: NODE, - args: [ - 'scripts/functional_tests', - '--config', - 'test/examples/config.js', - '--bail', - '--debug', - ], - }), - - functionalTests: scriptWithGithubChecks({ - title: 'Functional tests', - cmd: NODE, - args: [ - 'scripts/functional_tests', - '--config', - 'test/functional/config.js', - '--bail', - '--debug', - ], - }), - - licenses: scriptWithGithubChecks({ - title: 'Check licenses', - cmd: NODE, - args: ['scripts/check_licenses', '--dev'], - }), - - test_jest: gruntTaskWithGithubChecks('Jest tests', 'test:jest'), - test_jest_integration: gruntTaskWithGithubChecks( - 'Jest integration tests', - 'test:jest_integration' - ), - test_projects: gruntTaskWithGithubChecks('Project tests', 'test:projects'), - }; -}; diff --git a/tasks/jenkins.js b/tasks/jenkins.js deleted file mode 100644 index 890fef3442079..0000000000000 --- a/tasks/jenkins.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -module.exports = function (grunt) { - grunt.registerTask('jenkins:docs', ['docker:docs']); - - grunt.registerTask('jenkins:unit', [ - 'run:eslint', - 'run:sasslint', - 'run:checkTsProjects', - 'run:checkDocApiChanges', - 'run:typeCheck', - 'run:i18nCheck', - 'run:telemetryCheck', - 'run:checkFileCasing', - 'run:licenses', - 'run:verifyNotice', - 'run:mocha', - 'run:test_jest', - 'run:test_jest_integration', - 'run:test_projects', - 'run:test_hardening', - 'run:apiIntegrationTests', - ]); -}; diff --git a/tasks/test.js b/tasks/test.js deleted file mode 100644 index f370ea0b948c6..0000000000000 --- a/tasks/test.js +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { run } from '../utilities/visual_regression'; - -module.exports = function (grunt) { - grunt.registerTask( - 'test:visualRegression:buildGallery', - 'Compare screenshots and generate diff images.', - function () { - const done = this.async(); - run(done); - } - ); - - grunt.registerTask('test:quick', [ - 'checkPlugins', - 'run:mocha', - 'run:functionalTests', - 'test:jest', - 'test:jest_integration', - 'test:projects', - 'run:apiIntegrationTests', - ]); - - grunt.registerTask('test:mochaCoverage', ['run:mochaCoverage']); - - grunt.registerTask('test', (subTask) => { - if (subTask) grunt.fail.fatal(`invalid task "test:${subTask}"`); - - grunt.task.run( - [ - !grunt.option('quick') && 'run:eslint', - !grunt.option('quick') && 'run:sasslint', - !grunt.option('quick') && 'run:checkTsProjects', - !grunt.option('quick') && 'run:checkDocApiChanges', - !grunt.option('quick') && 'run:typeCheck', - !grunt.option('quick') && 'run:i18nCheck', - 'run:checkFileCasing', - 'run:licenses', - 'test:quick', - ].filter(Boolean) - ); - }); - - grunt.registerTask('quick-test', ['test:quick']); // historical alias - - grunt.registerTask('test:projects', function () { - const done = this.async(); - runProjectsTests().then(done, done); - }); - - function runProjectsTests() { - const serverCmd = { - cmd: 'yarn', - args: ['kbn', 'run', 'test', '--exclude', 'kibana', '--oss', '--skip-kibana-plugins'], - opts: { stdio: 'inherit' }, - }; - - return new Promise((resolve, reject) => { - grunt.util.spawn(serverCmd, (error, result, code) => { - if (error || code !== 0) { - const error = new Error(`projects tests exited with code ${code}`); - grunt.fail.fatal(error); - reject(error); - return; - } - - grunt.log.writeln(result); - resolve(); - }); - }); - } -}; diff --git a/tasks/test_jest.js b/tasks/test_jest.js deleted file mode 100644 index 810ed42324840..0000000000000 --- a/tasks/test_jest.js +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -const { resolve } = require('path'); - -module.exports = function (grunt) { - grunt.registerTask('test:jest', function () { - const done = this.async(); - runJest(resolve(__dirname, '../scripts/jest.js'), ['--maxWorkers=10']).then(done, done); - }); - - grunt.registerTask('test:jest_integration', function () { - const done = this.async(); - runJest(resolve(__dirname, '../scripts/jest_integration.js')).then(done, done); - }); - - function runJest(jestScript, args = []) { - const serverCmd = { - cmd: 'node', - args: [jestScript, '--ci', ...args], - opts: { stdio: 'inherit' }, - }; - - return new Promise((resolve, reject) => { - grunt.util.spawn(serverCmd, (error, result, code) => { - if (error || code !== 0) { - const error = new Error(`jest exited with code ${code}`); - grunt.fail.fatal(error); - reject(error); - return; - } - - grunt.log.writeln(result); - resolve(); - }); - }); - } -}; diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index f52343a9d913b..bd084fe1fb081 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -467,6 +467,13 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo await button.click(); } } + + /** + * Get visible text of the Welcome Banner + */ + async getWelcomeText() { + return await testSubjects.getVisibleText('global-banner-item'); + } } return new CommonPage(); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index 0e305eaafc82f..5b07cb0e534db 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -131,8 +131,8 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro } public async enterMarkdown(markdown: string) { - const input = await find.byCssSelector('.tvbMarkdownEditor__editor textarea'); await this.clearMarkdown(); + const input = await find.byCssSelector('.tvbMarkdownEditor__editor textarea'); await input.type(markdown); await PageObjects.common.sleep(3000); } @@ -147,14 +147,20 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro const value = $('.ace_line').text(); if (value.length > 0) { log.debug('Clearing text area input'); - const input = await find.byCssSelector('.tvbMarkdownEditor__editor textarea'); - await input.clearValueWithKeyboard(); + this.waitForMarkdownTextAreaCleaned(); } return value.length === 0; }); } + public async waitForMarkdownTextAreaCleaned() { + const input = await find.byCssSelector('.tvbMarkdownEditor__editor textarea'); + await input.clearValueWithKeyboard(); + const text = await this.getMarkdownText(); + return text.length === 0; + } + public async getMarkdownText(): Promise { const el = await find.byCssSelector('.tvbEditorVisualization'); const text = await el.getVisibleText(); diff --git a/test/scripts/checks/bundle_limits.sh b/test/scripts/checks/bundle_limits.sh index 10d9d9343fda4..cfe08d73bb558 100755 --- a/test/scripts/checks/bundle_limits.sh +++ b/test/scripts/checks/bundle_limits.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -node scripts/build_kibana_platform_plugins --validate-limits +checks-reporter-with-killswitch "Check Bundle Limits" \ + node scripts/build_kibana_platform_plugins --validate-limits diff --git a/test/scripts/checks/doc_api_changes.sh b/test/scripts/checks/doc_api_changes.sh index 503d12b2f6d73..f2f508fd8f7d4 100755 --- a/test/scripts/checks/doc_api_changes.sh +++ b/test/scripts/checks/doc_api_changes.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:checkDocApiChanges +checks-reporter-with-killswitch "Check Doc API Changes" \ + node scripts/check_published_api_changes diff --git a/test/scripts/checks/file_casing.sh b/test/scripts/checks/file_casing.sh index 513664263791b..b30dfaab62a98 100755 --- a/test/scripts/checks/file_casing.sh +++ b/test/scripts/checks/file_casing.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:checkFileCasing +checks-reporter-with-killswitch "Check File Casing" \ + node scripts/check_file_casing --quiet diff --git a/test/scripts/checks/i18n.sh b/test/scripts/checks/i18n.sh index 7a6fd46c46c76..e7a2060aaa73a 100755 --- a/test/scripts/checks/i18n.sh +++ b/test/scripts/checks/i18n.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:i18nCheck +checks-reporter-with-killswitch "Check i18n" \ + node scripts/i18n_check --ignore-missing diff --git a/test/scripts/checks/jest_configs.sh b/test/scripts/checks/jest_configs.sh index 28cb1386c748f..67fbee0b9fdf0 100644 --- a/test/scripts/checks/jest_configs.sh +++ b/test/scripts/checks/jest_configs.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -checks-reporter-with-killswitch "Check Jest Configs" node scripts/check_jest_configs +checks-reporter-with-killswitch "Check Jest Configs" \ + node scripts/check_jest_configs diff --git a/test/scripts/checks/licenses.sh b/test/scripts/checks/licenses.sh index a08d7d07a24a1..22494f11ce77c 100755 --- a/test/scripts/checks/licenses.sh +++ b/test/scripts/checks/licenses.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:licenses +checks-reporter-with-killswitch "Check Licenses" \ + node scripts/check_licenses --dev diff --git a/test/scripts/checks/mocha_coverage.sh b/test/scripts/checks/mocha_coverage.sh new file mode 100644 index 0000000000000..e1afad0ab775f --- /dev/null +++ b/test/scripts/checks/mocha_coverage.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +source src/dev/ci_setup/setup_env.sh + +yarn nyc --reporter=html --reporter=json-summary --report-dir=./target/kibana-coverage/mocha node scripts/mocha diff --git a/test/scripts/checks/plugins_with_circular_deps.sh b/test/scripts/checks/plugins_with_circular_deps.sh index 77880243538d2..a608d7e7b2edf 100644 --- a/test/scripts/checks/plugins_with_circular_deps.sh +++ b/test/scripts/checks/plugins_with_circular_deps.sh @@ -2,5 +2,5 @@ source src/dev/ci_setup/setup_env.sh -checks-reporter-with-killswitch "Check plugins with circular dependencies" \ +checks-reporter-with-killswitch "Check Plugins With Circular Dependencies" \ node scripts/find_plugins_with_circular_deps diff --git a/test/scripts/checks/telemetry.sh b/test/scripts/checks/telemetry.sh index c74ec295b385c..1622704b1fa92 100755 --- a/test/scripts/checks/telemetry.sh +++ b/test/scripts/checks/telemetry.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:telemetryCheck +checks-reporter-with-killswitch "Check Telemetry Schema" \ + node scripts/telemetry_check diff --git a/test/scripts/checks/test_hardening.sh b/test/scripts/checks/test_hardening.sh index 9184758577654..cd0c5a7d3c3aa 100755 --- a/test/scripts/checks/test_hardening.sh +++ b/test/scripts/checks/test_hardening.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:test_hardening +checks-reporter-with-killswitch "Test Hardening" \ + node scripts/test_hardening diff --git a/test/scripts/checks/test_projects.sh b/test/scripts/checks/test_projects.sh index 5f9aafe80e10e..56f15f6839e9d 100755 --- a/test/scripts/checks/test_projects.sh +++ b/test/scripts/checks/test_projects.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:test_projects +checks-reporter-with-killswitch "Test Projects" \ + yarn kbn run test --exclude kibana --oss --skip-kibana-plugins diff --git a/test/scripts/checks/ts_projects.sh b/test/scripts/checks/ts_projects.sh index d667c753baec2..467beb2977efc 100755 --- a/test/scripts/checks/ts_projects.sh +++ b/test/scripts/checks/ts_projects.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:checkTsProjects +checks-reporter-with-killswitch "Check TypeScript Projects" \ + node scripts/check_ts_projects diff --git a/test/scripts/checks/type_check.sh b/test/scripts/checks/type_check.sh index 07c49638134be..5e091625de4ed 100755 --- a/test/scripts/checks/type_check.sh +++ b/test/scripts/checks/type_check.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:typeCheck +checks-reporter-with-killswitch "Check Types" \ + node scripts/type_check diff --git a/test/scripts/checks/verify_notice.sh b/test/scripts/checks/verify_notice.sh index 9f8343e540861..99bfd55edd3c1 100755 --- a/test/scripts/checks/verify_notice.sh +++ b/test/scripts/checks/verify_notice.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:verifyNotice +checks-reporter-with-killswitch "Verify NOTICE" \ + node scripts/notice --validate diff --git a/test/scripts/jenkins_ci_group.sh b/test/scripts/jenkins_ci_group.sh index f9e9d40cd8b0d..4faf645975c77 100755 --- a/test/scripts/jenkins_ci_group.sh +++ b/test/scripts/jenkins_ci_group.sh @@ -13,9 +13,9 @@ if [[ -z "$CODE_COVERAGE" ]]; then if [[ ! "$TASK_QUEUE_PROCESS_ID" && "$CI_GROUP" == "1" ]]; then source test/scripts/jenkins_build_kbn_sample_panel_action.sh - yarn run grunt run:pluginFunctionalTestsRelease --from=source; - yarn run grunt run:exampleFunctionalTestsRelease --from=source; - yarn run grunt run:interpreterFunctionalTestsRelease; + ./test/scripts/test/plugin_functional.sh + ./test/scripts/test/example_functional.sh + ./test/scripts/test/interpreter_functional.sh fi else echo " -> Running Functional tests with code coverage" diff --git a/test/scripts/jenkins_docs.sh b/test/scripts/jenkins_docs.sh index bd606d60101d8..f447afda1f948 100755 --- a/test/scripts/jenkins_docs.sh +++ b/test/scripts/jenkins_docs.sh @@ -3,4 +3,4 @@ set -e source "$(dirname $0)/../../src/dev/ci_setup/setup.sh" -"$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:docs; +"$(FORCE_COLOR=0 yarn bin)/grunt" docker:docs; diff --git a/test/scripts/jenkins_plugin_functional.sh b/test/scripts/jenkins_plugin_functional.sh index 1d691d98982de..1811bdeb4ed4b 100755 --- a/test/scripts/jenkins_plugin_functional.sh +++ b/test/scripts/jenkins_plugin_functional.sh @@ -10,6 +10,6 @@ cd -; pwd -yarn run grunt run:pluginFunctionalTestsRelease --from=source; -yarn run grunt run:exampleFunctionalTestsRelease --from=source; -yarn run grunt run:interpreterFunctionalTestsRelease; +./test/scripts/test/plugin_functional.sh +./test/scripts/test/example_functional.sh +./test/scripts/test/interpreter_functional.sh diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index 1f6a3d440734b..c788a4a5b01ae 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -9,20 +9,43 @@ rename_coverage_file() { } if [[ -z "$CODE_COVERAGE" ]] ; then - "$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:unit --dev; + # Lint + ./test/scripts/lint/eslint.sh + ./test/scripts/lint/sasslint.sh + + # Test + ./test/scripts/test/jest_integration.sh + ./test/scripts/test/mocha.sh + ./test/scripts/test/jest_unit.sh + ./test/scripts/test/api_integration.sh + + # Check + ./test/scripts/checks/telemetry.sh + ./test/scripts/checks/ts_projects.sh + ./test/scripts/checks/jest_configs.sh + ./test/scripts/checks/doc_api_changes.sh + ./test/scripts/checks/type_check.sh + ./test/scripts/checks/bundle_limits.sh + ./test/scripts/checks/i18n.sh + ./test/scripts/checks/file_casing.sh + ./test/scripts/checks/licenses.sh + ./test/scripts/checks/plugins_with_circular_deps.sh + ./test/scripts/checks/verify_notice.sh + ./test/scripts/checks/test_projects.sh + ./test/scripts/checks/test_hardening.sh else - echo " -> Running jest tests with coverage" - node scripts/jest --ci --verbose --coverage - rename_coverage_file "oss" - echo "" - echo "" - echo " -> Running jest integration tests with coverage" - node --max-old-space-size=8192 scripts/jest_integration --ci --verbose --coverage || true; - rename_coverage_file "oss-integration" - echo "" - echo "" + # echo " -> Running jest tests with coverage" + # node scripts/jest --ci --verbose --coverage + # rename_coverage_file "oss" + # echo "" + # echo "" + # echo " -> Running jest integration tests with coverage" + # node --max-old-space-size=8192 scripts/jest_integration --ci --verbose --coverage || true; + # rename_coverage_file "oss-integration" + # echo "" + # echo "" echo " -> Running mocha tests with coverage" - yarn run grunt "test:mochaCoverage"; + ./test/scripts/checks/mocha_coverage.sh echo "" echo "" fi diff --git a/test/scripts/lint/eslint.sh b/test/scripts/lint/eslint.sh index c3211300b96c5..053150e42f409 100755 --- a/test/scripts/lint/eslint.sh +++ b/test/scripts/lint/eslint.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:eslint +checks-reporter-with-killswitch "Lint: eslint" \ + node scripts/eslint --no-cache diff --git a/test/scripts/lint/sasslint.sh b/test/scripts/lint/sasslint.sh index b9c683bcb049e..72e341cdcda16 100755 --- a/test/scripts/lint/sasslint.sh +++ b/test/scripts/lint/sasslint.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:sasslint +checks-reporter-with-killswitch "Lint: sasslint" \ + node scripts/sasslint diff --git a/test/scripts/server_integration.sh b/test/scripts/server_integration.sh deleted file mode 100755 index 82bc733e51b26..0000000000000 --- a/test/scripts/server_integration.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -source test/scripts/jenkins_test_setup_oss.sh - -yarn run grunt run:serverIntegrationTests diff --git a/test/scripts/test/api_integration.sh b/test/scripts/test/api_integration.sh index 152c97a3ca7df..bf6f683989fe5 100755 --- a/test/scripts/test/api_integration.sh +++ b/test/scripts/test/api_integration.sh @@ -2,4 +2,8 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:apiIntegrationTests +checks-reporter-with-killswitch "API Integration Tests" \ + node scripts/functional_tests \ + --config test/api_integration/config.js \ + --bail \ + --debug diff --git a/test/scripts/test/example_functional.sh b/test/scripts/test/example_functional.sh new file mode 100755 index 0000000000000..08915085505bc --- /dev/null +++ b/test/scripts/test/example_functional.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +source test/scripts/jenkins_test_setup_oss.sh + +checks-reporter-with-killswitch "Example Functional Tests" \ + node scripts/functional_tests \ + --config test/examples/config.js \ + --bail \ + --debug diff --git a/test/scripts/test/interpreter_functional.sh b/test/scripts/test/interpreter_functional.sh new file mode 100755 index 0000000000000..1558989c0fdfc --- /dev/null +++ b/test/scripts/test/interpreter_functional.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +source test/scripts/jenkins_test_setup_oss.sh + +checks-reporter-with-killswitch "Interpreter Functional Tests" \ + node scripts/functional_tests \ + --config test/interpreter_functional/config.ts \ + --bail \ + --debug \ + --kibana-install-dir $KIBANA_INSTALL_DIR diff --git a/test/scripts/test/jest_integration.sh b/test/scripts/test/jest_integration.sh index 73dbbddfb38f6..8791248e9a166 100755 --- a/test/scripts/test/jest_integration.sh +++ b/test/scripts/test/jest_integration.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:test_jest_integration +checks-reporter-with-killswitch "Jest Integration Tests" \ + node scripts/jest_integration diff --git a/test/scripts/test/jest_unit.sh b/test/scripts/test/jest_unit.sh index e25452698cebc..de5e16c2b1366 100755 --- a/test/scripts/test/jest_unit.sh +++ b/test/scripts/test/jest_unit.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:test_jest +checks-reporter-with-killswitch "Jest Unit Tests" \ + node scripts/jest diff --git a/test/scripts/test/karma_ci.sh b/test/scripts/test/karma_ci.sh deleted file mode 100755 index e9985300ba19d..0000000000000 --- a/test/scripts/test/karma_ci.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -source src/dev/ci_setup/setup_env.sh - -yarn run grunt run:test_karma_ci diff --git a/test/scripts/test/mocha.sh b/test/scripts/test/mocha.sh index 43c00f0a09dcf..e5f3259926e42 100755 --- a/test/scripts/test/mocha.sh +++ b/test/scripts/test/mocha.sh @@ -2,4 +2,5 @@ source src/dev/ci_setup/setup_env.sh -yarn run grunt run:mocha +checks-reporter-with-killswitch "Mocha Tests" \ + node scripts/mocha diff --git a/test/scripts/test/plugin_functional.sh b/test/scripts/test/plugin_functional.sh new file mode 100755 index 0000000000000..e0af062e1de4a --- /dev/null +++ b/test/scripts/test/plugin_functional.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +source test/scripts/jenkins_test_setup_oss.sh + +checks-reporter-with-killswitch "Plugin Functional Tests" \ + node scripts/functional_tests \ + --config test/plugin_functional/config.ts \ + --bail \ + --debug diff --git a/test/scripts/test/server_integration.sh b/test/scripts/test/server_integration.sh new file mode 100755 index 0000000000000..1ff4a772bb6e0 --- /dev/null +++ b/test/scripts/test/server_integration.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +source test/scripts/jenkins_test_setup_oss.sh + +checks-reporter-with-killswitch "Server Integration Tests" \ + node scripts/functional_tests \ + --config test/server_integration/http/ssl/config.js \ + --config test/server_integration/http/ssl_redirect/config.js \ + --config test/server_integration/http/platform/config.ts \ + --config test/server_integration/http/ssl_with_p12/config.js \ + --config test/server_integration/http/ssl_with_p12_intermediate/config.js \ + --bail \ + --debug \ + --kibana-install-dir $KIBANA_INSTALL_DIR diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index 521672e4bf48c..422a6c188979d 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -59,7 +59,7 @@ def uploadBaseWebsiteFiles(prefix) { def uploadCoverageHtmls(prefix) { [ 'target/kibana-coverage/functional-combined', - 'target/kibana-coverage/jest-combined', + // 'target/kibana-coverage/jest-combined', skipped due to failures 'target/kibana-coverage/mocha-combined', ].each { uploadWithVault(prefix, it) } } @@ -200,13 +200,14 @@ def ingest(jobName, buildNumber, buildUrl, timestamp, previousSha, teamAssignmen def runTests() { parallel([ 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), - 'x-pack-intake-agent': { - withEnv([ - 'NODE_ENV=test' // Needed for jest tests only - ]) { - workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh')() - } - }, + // skipping due to failures + // 'x-pack-intake-agent': { + // withEnv([ + // 'NODE_ENV=test' // Needed for jest tests only + // ]) { + // workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh')() + // } + // }, 'kibana-oss-agent' : workers.functional( 'kibana-oss-tests', { kibanaPipeline.buildOss() }, diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 348da83cc1364..22f446eeb00da 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -75,7 +75,7 @@ def functionalOss(Map params = [:]) { } if (config.serverIntegration) { - task(kibanaPipeline.scriptTaskDocker('serverIntegration', './test/scripts/server_integration.sh')) + task(kibanaPipeline.scriptTaskDocker('serverIntegration', './test/scripts/test/server_integration.sh')) } } } diff --git a/x-pack/plugins/alerts/common/disabled_action_groups.test.ts b/x-pack/plugins/alerts/common/disabled_action_groups.test.ts new file mode 100644 index 0000000000000..96db7bfd8710d --- /dev/null +++ b/x-pack/plugins/alerts/common/disabled_action_groups.test.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isActionGroupDisabledForActionTypeId } from './disabled_action_groups'; +import { RecoveredActionGroup } from './builtin_action_groups'; + +test('returns false if action group id has no disabled types', () => { + expect(isActionGroupDisabledForActionTypeId('enabledActionGroup', '.jira')).toBeFalsy(); +}); + +test('returns false if action group id does not contains type', () => { + expect(isActionGroupDisabledForActionTypeId(RecoveredActionGroup.id, '.email')).toBeFalsy(); +}); + +test('returns true if action group id does contain type', () => { + expect(isActionGroupDisabledForActionTypeId(RecoveredActionGroup.id, '.jira')).toBeTruthy(); +}); diff --git a/x-pack/plugins/alerts/common/disabled_action_groups.ts b/x-pack/plugins/alerts/common/disabled_action_groups.ts new file mode 100644 index 0000000000000..525a267a278ea --- /dev/null +++ b/x-pack/plugins/alerts/common/disabled_action_groups.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { RecoveredActionGroup } from './builtin_action_groups'; + +const DisabledActionGroupsByActionType: Record = { + [RecoveredActionGroup.id]: ['.jira', '.servicenow', '.resilient'], +}; + +export const DisabledActionTypeIdsForActionGroup: Map = new Map( + Object.entries(DisabledActionGroupsByActionType) +); + +export function isActionGroupDisabledForActionTypeId( + actionGroup: string, + actionTypeId: string +): boolean { + return ( + DisabledActionTypeIdsForActionGroup.has(actionGroup) && + DisabledActionTypeIdsForActionGroup.get(actionGroup)!.includes(actionTypeId) + ); +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index 4d0e7bf7eb0bc..3e551facd98a0 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -13,6 +13,7 @@ export * from './alert_task_instance'; export * from './alert_navigation'; export * from './alert_instance_summary'; export * from './builtin_action_groups'; +export * from './disabled_action_groups'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 956568fbe7632..d0757ca5111b6 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -28,6 +28,7 @@ interface SetupDependencies { export class EnhancedDataServerPlugin implements Plugin { private readonly logger: Logger; + private sessionService!: BackgroundSessionService; constructor(private initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('data_enhanced'); @@ -53,10 +54,12 @@ export class EnhancedDataServerPlugin implements Plugin new Promise((resolve) => setImmediate(resolve)); describe('BackgroundSessionService', () => { let savedObjectsClient: jest.Mocked; let service: BackgroundSessionService; + const MOCK_SESSION_ID = 'session-id-mock'; + const MOCK_ASYNC_ID = '123456'; + const MOCK_KEY_HASH = '608de49a4600dbb5b173492759792e4a'; + + const createMockInternalSavedObjectClient = ( + findSpy?: jest.SpyInstance, + bulkUpdateSpy?: jest.SpyInstance + ) => { + Object.defineProperty(service, 'internalSavedObjectsClient', { + get: () => { + const find = + findSpy || + (() => { + return { + saved_objects: [ + { + attributes: { + sessionId: MOCK_SESSION_ID, + idMapping: { + 'another-key': 'another-async-id', + }, + }, + id: MOCK_SESSION_ID, + version: '1', + }, + ], + }; + }); + + const bulkUpdate = + bulkUpdateSpy || + (() => { + return { + saved_objects: [], + }; + }); + return { + find, + bulkUpdate, + }; + }, + }); + }; + + const createMockIdMapping = ( + mapValues: any[], + insertTime?: moment.Moment, + retryCount?: number + ): Map => { + const fakeMap = new Map(); + fakeMap.set(MOCK_SESSION_ID, { + ids: new Map(mapValues), + insertTime: insertTime || moment(), + retryCount: retryCount || 0, + }); + return fakeMap; + }; + const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489'; const mockSavedObject: SavedObject = { id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', @@ -30,9 +99,14 @@ describe('BackgroundSessionService', () => { references: [], }; - beforeEach(() => { + beforeEach(async () => { savedObjectsClient = savedObjectsClientMock.create(); - service = new BackgroundSessionService(); + const mockLogger: any = { + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + service = new BackgroundSessionService(mockLogger); }); it('search throws if `name` is not provided', () => { @@ -199,6 +273,13 @@ describe('BackgroundSessionService', () => { const created = new Date().toISOString(); const expires = new Date().toISOString(); + const mockIdMapping = createMockIdMapping([]); + const setSpy = jest.fn(); + mockIdMapping.set = setSpy; + Object.defineProperty(service, 'sessionSearchMap', { + get: () => mockIdMapping, + }); + await service.trackId( searchRequest, searchId, @@ -223,12 +304,17 @@ describe('BackgroundSessionService', () => { initialState: {}, restoreState: {}, status: BackgroundSessionStatus.IN_PROGRESS, - idMapping: { [requestHash]: searchId }, + idMapping: {}, appId, urlGeneratorId, + sessionId, }, { id: sessionId } ); + + const [setSessionId, setParams] = setSpy.mock.calls[0]; + expect(setParams.ids.get(requestHash)).toBe(searchId); + expect(setSessionId).toBe(sessionId); }); it('updates saved object when `isStored` is `true`', async () => { @@ -309,4 +395,204 @@ describe('BackgroundSessionService', () => { expect(id).toBe(searchId); }); }); + + describe('Monitor', () => { + beforeEach(async () => { + jest.useFakeTimers(); + const config$ = new BehaviorSubject({ + search: { + sendToBackground: { + enabled: true, + }, + }, + }); + await service.start(coreMock.createStart(), config$); + await flushPromises(); + }); + + afterEach(() => { + jest.useRealTimers(); + service.stop(); + }); + + it('schedules the next iteration', async () => { + const findSpy = jest.fn().mockResolvedValue({ saved_objects: [] }); + createMockInternalSavedObjectClient(findSpy); + + const mockIdMapping = createMockIdMapping([[MOCK_KEY_HASH, MOCK_ASYNC_ID]], moment()); + + Object.defineProperty(service, 'sessionSearchMap', { + get: () => mockIdMapping, + }); + + jest.advanceTimersByTime(INMEM_TRACKING_INTERVAL); + expect(findSpy).toHaveBeenCalledTimes(1); + await flushPromises(); + + jest.advanceTimersByTime(INMEM_TRACKING_INTERVAL); + expect(findSpy).toHaveBeenCalledTimes(2); + }); + + it('should delete expired IDs', async () => { + const findSpy = jest.fn().mockResolvedValueOnce({ saved_objects: [] }); + createMockInternalSavedObjectClient(findSpy); + + const mockIdMapping = createMockIdMapping( + [[MOCK_KEY_HASH, MOCK_ASYNC_ID]], + moment().subtract(2, 'm') + ); + + const deleteSpy = jest.spyOn(mockIdMapping, 'delete'); + Object.defineProperty(service, 'sessionSearchMap', { + get: () => mockIdMapping, + }); + + // Get setInterval to fire + jest.advanceTimersByTime(INMEM_TRACKING_INTERVAL); + + expect(findSpy).not.toHaveBeenCalled(); + expect(deleteSpy).toHaveBeenCalledTimes(1); + }); + + it('should delete IDs that passed max retries', async () => { + const findSpy = jest.fn().mockResolvedValueOnce({ saved_objects: [] }); + createMockInternalSavedObjectClient(findSpy); + + const mockIdMapping = createMockIdMapping( + [[MOCK_KEY_HASH, MOCK_ASYNC_ID]], + moment(), + MAX_UPDATE_RETRIES + ); + + const deleteSpy = jest.spyOn(mockIdMapping, 'delete'); + Object.defineProperty(service, 'sessionSearchMap', { + get: () => mockIdMapping, + }); + + // Get setInterval to fire + jest.advanceTimersByTime(INMEM_TRACKING_INTERVAL); + + expect(findSpy).not.toHaveBeenCalled(); + expect(deleteSpy).toHaveBeenCalledTimes(1); + }); + + it('should not fetch when no IDs are mapped', async () => { + const findSpy = jest.fn().mockResolvedValueOnce({ saved_objects: [] }); + createMockInternalSavedObjectClient(findSpy); + + jest.advanceTimersByTime(INMEM_TRACKING_INTERVAL); + expect(findSpy).not.toHaveBeenCalled(); + }); + + it('should try to fetch saved objects if some ids are mapped', async () => { + const mockIdMapping = createMockIdMapping([[MOCK_KEY_HASH, MOCK_ASYNC_ID]]); + Object.defineProperty(service, 'sessionSearchMap', { + get: () => mockIdMapping, + }); + + const findSpy = jest.fn().mockResolvedValueOnce({ saved_objects: [] }); + const bulkUpdateSpy = jest.fn().mockResolvedValueOnce({ saved_objects: [] }); + createMockInternalSavedObjectClient(findSpy, bulkUpdateSpy); + + jest.advanceTimersByTime(INMEM_TRACKING_INTERVAL); + expect(findSpy).toHaveBeenCalledTimes(1); + expect(bulkUpdateSpy).not.toHaveBeenCalled(); + }); + + it('should update saved objects if they are found, and delete session on success', async () => { + const mockIdMapping = createMockIdMapping([[MOCK_KEY_HASH, MOCK_ASYNC_ID]], undefined, 1); + const mockMapDeleteSpy = jest.fn(); + const mockSessionDeleteSpy = jest.fn(); + mockIdMapping.delete = mockMapDeleteSpy; + mockIdMapping.get(MOCK_SESSION_ID)!.ids.delete = mockSessionDeleteSpy; + Object.defineProperty(service, 'sessionSearchMap', { + get: () => mockIdMapping, + }); + + const findSpy = jest.fn().mockResolvedValueOnce({ + saved_objects: [ + { + id: MOCK_SESSION_ID, + attributes: { + idMapping: { + b: 'c', + }, + }, + }, + ], + }); + const bulkUpdateSpy = jest.fn().mockResolvedValueOnce({ + saved_objects: [ + { + id: MOCK_SESSION_ID, + attributes: { + idMapping: { + b: 'c', + [MOCK_KEY_HASH]: MOCK_ASYNC_ID, + }, + }, + }, + ], + }); + createMockInternalSavedObjectClient(findSpy, bulkUpdateSpy); + + jest.advanceTimersByTime(INMEM_TRACKING_INTERVAL); + + // Release timers to call check after test actions are done. + jest.useRealTimers(); + await new Promise((r) => setTimeout(r, 15)); + + expect(findSpy).toHaveBeenCalledTimes(1); + expect(bulkUpdateSpy).toHaveBeenCalledTimes(1); + expect(mockSessionDeleteSpy).toHaveBeenCalledTimes(2); + expect(mockSessionDeleteSpy).toBeCalledWith('b'); + expect(mockSessionDeleteSpy).toBeCalledWith(MOCK_KEY_HASH); + expect(mockMapDeleteSpy).toBeCalledTimes(1); + }); + + it('should update saved objects if they are found, and increase retryCount on error', async () => { + const mockIdMapping = createMockIdMapping([[MOCK_KEY_HASH, MOCK_ASYNC_ID]]); + const mockMapDeleteSpy = jest.fn(); + const mockSessionDeleteSpy = jest.fn(); + mockIdMapping.delete = mockMapDeleteSpy; + mockIdMapping.get(MOCK_SESSION_ID)!.ids.delete = mockSessionDeleteSpy; + Object.defineProperty(service, 'sessionSearchMap', { + get: () => mockIdMapping, + }); + + const findSpy = jest.fn().mockResolvedValueOnce({ + saved_objects: [ + { + id: MOCK_SESSION_ID, + attributes: { + idMapping: { + b: 'c', + }, + }, + }, + ], + }); + const bulkUpdateSpy = jest.fn().mockResolvedValueOnce({ + saved_objects: [ + { + id: MOCK_SESSION_ID, + error: 'not ok', + }, + ], + }); + createMockInternalSavedObjectClient(findSpy, bulkUpdateSpy); + + jest.advanceTimersByTime(INMEM_TRACKING_INTERVAL); + + // Release timers to call check after test actions are done. + jest.useRealTimers(); + await new Promise((r) => setTimeout(r, 15)); + + expect(findSpy).toHaveBeenCalledTimes(1); + expect(bulkUpdateSpy).toHaveBeenCalledTimes(1); + expect(mockSessionDeleteSpy).not.toHaveBeenCalled(); + expect(mockMapDeleteSpy).not.toHaveBeenCalled(); + expect(mockIdMapping.get(MOCK_SESSION_ID)!.retryCount).toBe(1); + }); + }); }); diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts index 65a9e0901d738..96d66157c48ec 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts @@ -4,9 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreStart, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import moment, { Moment } from 'moment'; import { from, Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { first, switchMap } from 'rxjs/operators'; +import { + CoreStart, + KibanaRequest, + SavedObjectsClient, + SavedObjectsClientContract, + Logger, + SavedObject, +} from '../../../../../../src/core/server'; import { IKibanaSearchRequest, IKibanaSearchResponse, @@ -25,21 +33,154 @@ import { } from '../../../common'; import { BACKGROUND_SESSION_TYPE } from '../../saved_objects'; import { createRequestHash } from './utils'; +import { ConfigSchema } from '../../../config'; +const INMEM_MAX_SESSIONS = 10000; const DEFAULT_EXPIRATION = 7 * 24 * 60 * 60 * 1000; +export const INMEM_TRACKING_INTERVAL = 10 * 1000; +export const INMEM_TRACKING_TIMEOUT_SEC = 60; +export const MAX_UPDATE_RETRIES = 3; export interface BackgroundSessionDependencies { savedObjectsClient: SavedObjectsClientContract; } +export interface SessionInfo { + insertTime: Moment; + retryCount: number; + ids: Map; +} + export class BackgroundSessionService implements ISessionService { /** * Map of sessionId to { [requestHash]: searchId } * @private */ - private sessionSearchMap = new Map>(); + private sessionSearchMap = new Map(); + private internalSavedObjectsClient!: SavedObjectsClientContract; + private monitorTimer!: NodeJS.Timeout; + + constructor(private readonly logger: Logger) {} + + public async start(core: CoreStart, config$: Observable) { + return this.setupMonitoring(core, config$); + } + + public stop() { + this.sessionSearchMap.clear(); + clearTimeout(this.monitorTimer); + } + + private setupMonitoring = async (core: CoreStart, config$: Observable) => { + const config = await config$.pipe(first()).toPromise(); + if (config.search.sendToBackground.enabled) { + this.logger.debug(`setupMonitoring | Enabling monitoring`); + const internalRepo = core.savedObjects.createInternalRepository([BACKGROUND_SESSION_TYPE]); + this.internalSavedObjectsClient = new SavedObjectsClient(internalRepo); + this.monitorMappedIds(); + } + }; + + /** + * Gets all {@link SessionSavedObjectAttributes | Background Searches} that + * currently being tracked by the service. + * + * @remarks + * Uses `internalSavedObjectsClient` as this is called asynchronously, not within the + * context of a user's session. + */ + private async getAllMappedSavedObjects() { + const activeMappingIds = Array.from(this.sessionSearchMap.keys()) + .map((sessionId) => `"${sessionId}"`) + .join(' | '); + const res = await this.internalSavedObjectsClient.find({ + perPage: INMEM_MAX_SESSIONS, // If there are more sessions in memory, they will be synced when some items are cleared out. + type: BACKGROUND_SESSION_TYPE, + search: activeMappingIds, + searchFields: ['sessionId'], + namespaces: ['*'], + }); + this.logger.debug(`getAllMappedSavedObjects | Got ${res.saved_objects.length} items`); + return res.saved_objects; + } - constructor() {} + private clearSessions = () => { + const curTime = moment(); + + this.sessionSearchMap.forEach((sessionInfo, sessionId) => { + if ( + moment.duration(curTime.diff(sessionInfo.insertTime)).asSeconds() > + INMEM_TRACKING_TIMEOUT_SEC + ) { + this.logger.debug(`clearSessions | Deleting expired session ${sessionId}`); + this.sessionSearchMap.delete(sessionId); + } else if (sessionInfo.retryCount >= MAX_UPDATE_RETRIES) { + this.logger.warn(`clearSessions | Deleting failed session ${sessionId}`); + this.sessionSearchMap.delete(sessionId); + } + }); + }; + + private async monitorMappedIds() { + this.monitorTimer = setTimeout(async () => { + try { + this.clearSessions(); + + if (!this.sessionSearchMap.size) return; + this.logger.debug(`monitorMappedIds | Map contains ${this.sessionSearchMap.size} items`); + + const savedSessions = await this.getAllMappedSavedObjects(); + const updatedSessions = await this.updateAllSavedObjects(savedSessions); + + updatedSessions.forEach((updatedSavedObject) => { + const sessionInfo = this.sessionSearchMap.get(updatedSavedObject.id)!; + if (updatedSavedObject.error) { + // Retry next time + sessionInfo.retryCount++; + } else if (updatedSavedObject.attributes.idMapping) { + // Delete the ids that we just saved, avoiding a potential new ids being lost. + Object.keys(updatedSavedObject.attributes.idMapping).forEach((key) => { + sessionInfo.ids.delete(key); + }); + // If the session object is empty, delete it as well + if (!sessionInfo.ids.entries.length) { + this.sessionSearchMap.delete(updatedSavedObject.id); + } else { + sessionInfo.retryCount = 0; + } + } + }); + } catch (e) { + this.logger.error(`monitorMappedIds | Error while updating sessions. ${e}`); + } finally { + this.monitorMappedIds(); + } + }, INMEM_TRACKING_INTERVAL); + } + + private async updateAllSavedObjects( + activeMappingObjects: Array> + ) { + if (!activeMappingObjects.length) return []; + + this.logger.debug(`updateAllSavedObjects | Updating ${activeMappingObjects.length} items`); + const updatedSessions = activeMappingObjects + .filter((so) => !so.error) + .map((sessionSavedObject) => { + const sessionInfo = this.sessionSearchMap.get(sessionSavedObject.id); + const idMapping = sessionInfo ? Object.fromEntries(sessionInfo.ids.entries()) : {}; + sessionSavedObject.attributes.idMapping = { + ...sessionSavedObject.attributes.idMapping, + ...idMapping, + }; + return sessionSavedObject; + }); + + const updateResults = await this.internalSavedObjectsClient.bulkUpdate( + updatedSessions + ); + return updateResults.saved_objects; + } public search( strategy: ISearchStrategy, @@ -85,9 +226,8 @@ export class BackgroundSessionService implements ISessionService { if (!appId) throw new Error('AppId is required'); if (!urlGeneratorId) throw new Error('UrlGeneratorId is required'); - // Get the mapping of request hash/search ID for this session - const searchMap = this.sessionSearchMap.get(sessionId) ?? new Map(); - const idMapping = Object.fromEntries(searchMap.entries()); + this.logger.debug(`save | ${sessionId}`); + const attributes = { name, created, @@ -95,9 +235,10 @@ export class BackgroundSessionService implements ISessionService { status, initialState, restoreState, - idMapping, + idMapping: {}, urlGeneratorId, appId, + sessionId, }; const session = await savedObjectsClient.create( BACKGROUND_SESSION_TYPE, @@ -105,14 +246,12 @@ export class BackgroundSessionService implements ISessionService { { id: sessionId } ); - // Clear out the entries for this session ID so they don't get saved next time - this.sessionSearchMap.delete(sessionId); - return session; }; // TODO: Throw an error if this session doesn't belong to this user public get = (sessionId: string, { savedObjectsClient }: BackgroundSessionDependencies) => { + this.logger.debug(`get | ${sessionId}`); return savedObjectsClient.get( BACKGROUND_SESSION_TYPE, sessionId @@ -136,6 +275,7 @@ export class BackgroundSessionService implements ISessionService { attributes: Partial, { savedObjectsClient }: BackgroundSessionDependencies ) => { + this.logger.debug(`update | ${sessionId}`); return savedObjectsClient.update( BACKGROUND_SESSION_TYPE, sessionId, @@ -160,6 +300,7 @@ export class BackgroundSessionService implements ISessionService { deps: BackgroundSessionDependencies ) => { if (!sessionId || !searchId) return; + this.logger.debug(`trackId | ${sessionId} | ${searchId}`); const requestHash = createRequestHash(searchRequest.params); // If there is already a saved object for this session, update it to include this request/ID. @@ -168,8 +309,12 @@ export class BackgroundSessionService implements ISessionService { const attributes = { idMapping: { [requestHash]: searchId } }; await this.update(sessionId, attributes, deps); } else { - const map = this.sessionSearchMap.get(sessionId) ?? new Map(); - map.set(requestHash, searchId); + const map = this.sessionSearchMap.get(sessionId) ?? { + insertTime: moment(), + retryCount: 0, + ids: new Map(), + }; + map.ids.set(requestHash, searchId); this.sessionSearchMap.set(sessionId, map); } }; diff --git a/x-pack/plugins/data_enhanced/server/search/session/utils.ts b/x-pack/plugins/data_enhanced/server/search/session/utils.ts index 1c314c64f9be3..beaecc5a839d3 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/utils.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/utils.ts @@ -5,6 +5,7 @@ */ import { createHash } from 'crypto'; +import stringify from 'json-stable-stringify'; /** * Generate the hash for this request so that, in the future, this hash can be used to look up @@ -13,5 +14,5 @@ import { createHash } from 'crypto'; */ export function createRequestHash(keys: Record) { const { preference, ...params } = keys; - return createHash(`sha256`).update(JSON.stringify(params)).digest('hex'); + return createHash(`sha256`).update(stringify(params)).digest('hex'); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 8f798445b2362..56950d155f782 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -108,7 +108,8 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { const newPackagePolicy = { ...restOfPackagePolicy, inputs: inputs.map((input) => { - const { streams, ...restOfInput } = input; + // Remove `compiled_input` from all input info, we assign this after saving + const { streams, compiled_input: compiledInput, ...restOfInput } = input; return { ...restOfInput, streams: streams.map((stream) => { diff --git a/x-pack/plugins/global_search/public/mocks.ts b/x-pack/plugins/global_search/public/mocks.ts index 97dc01e92dbfe..8b0bfec66f61d 100644 --- a/x-pack/plugins/global_search/public/mocks.ts +++ b/x-pack/plugins/global_search/public/mocks.ts @@ -20,6 +20,7 @@ const createStartMock = (): jest.Mocked => { return { find: searchMock.find, + getSearchableTypes: searchMock.getSearchableTypes, }; }; diff --git a/x-pack/plugins/global_search/public/plugin.ts b/x-pack/plugins/global_search/public/plugin.ts index 6af8ec32a581d..a861911d935b4 100644 --- a/x-pack/plugins/global_search/public/plugin.ts +++ b/x-pack/plugins/global_search/public/plugin.ts @@ -45,13 +45,14 @@ export class GlobalSearchPlugin start({ http }: CoreStart, { licensing }: GlobalSearchPluginStartDeps) { this.licenseChecker = new LicenseChecker(licensing.license$); - const { find } = this.searchService.start({ + const { find, getSearchableTypes } = this.searchService.start({ http, licenseChecker: this.licenseChecker, }); return { find, + getSearchableTypes, }; } diff --git a/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts new file mode 100644 index 0000000000000..002ea0cff20d8 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { fetchServerSearchableTypes } from './fetch_server_searchable_types'; + +describe('fetchServerSearchableTypes', () => { + let http: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createStartContract(); + }); + + it('perform a GET request to the endpoint with valid options', () => { + http.get.mockResolvedValue({ results: [] }); + + fetchServerSearchableTypes(http); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith('/internal/global_search/searchable_types'); + }); + + it('returns the results from the server', async () => { + const types = ['typeA', 'typeB']; + + http.get.mockResolvedValue({ types }); + + const results = await fetchServerSearchableTypes(http); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(results).toEqual(types); + }); +}); diff --git a/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts new file mode 100644 index 0000000000000..c4a0724991870 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/fetch_server_searchable_types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpStart } from 'src/core/public'; + +interface ServerSearchableTypesResponse { + types: string[]; +} + +export const fetchServerSearchableTypes = async (http: HttpStart) => { + const { types } = await http.get( + '/internal/global_search/searchable_types' + ); + return types; +}; diff --git a/x-pack/plugins/global_search/public/services/search_service.mock.ts b/x-pack/plugins/global_search/public/services/search_service.mock.ts index eca69148288b9..0aa65e39f026c 100644 --- a/x-pack/plugins/global_search/public/services/search_service.mock.ts +++ b/x-pack/plugins/global_search/public/services/search_service.mock.ts @@ -7,17 +7,21 @@ import { SearchServiceSetup, SearchServiceStart } from './search_service'; import { of } from 'rxjs'; -const createSetupMock = (): jest.Mocked => { - return { +const createSetupMock = () => { + const mock: jest.Mocked = { registerResultProvider: jest.fn(), }; + + return mock; }; -const createStartMock = (): jest.Mocked => { - const mock = { +const createStartMock = () => { + const mock: jest.Mocked = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; mock.find.mockReturnValue(of({ results: [] })); + mock.getSearchableTypes.mockResolvedValue([]); return mock; }; diff --git a/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts b/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts index 1caabd6a1681c..bbc513c78759e 100644 --- a/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts +++ b/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts @@ -9,6 +9,11 @@ jest.doMock('./fetch_server_results', () => ({ fetchServerResults: fetchServerResultsMock, })); +export const fetchServerSearchableTypesMock = jest.fn(); +jest.doMock('./fetch_server_searchable_types', () => ({ + fetchServerSearchableTypes: fetchServerSearchableTypesMock, +})); + export const getDefaultPreferenceMock = jest.fn(); jest.doMock('./utils', () => { const original = jest.requireActual('./utils'); diff --git a/x-pack/plugins/global_search/public/services/search_service.test.ts b/x-pack/plugins/global_search/public/services/search_service.test.ts index 419ad847d6c29..297a27e3c837c 100644 --- a/x-pack/plugins/global_search/public/services/search_service.test.ts +++ b/x-pack/plugins/global_search/public/services/search_service.test.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fetchServerResultsMock, getDefaultPreferenceMock } from './search_service.test.mocks'; +import { + fetchServerResultsMock, + getDefaultPreferenceMock, + fetchServerSearchableTypesMock, +} from './search_service.test.mocks'; import { Observable, of } from 'rxjs'; import { take } from 'rxjs/operators'; @@ -41,10 +45,17 @@ describe('SearchService', () => { const createProvider = ( id: string, - source: Observable = of([]) + { + source = of([]), + types = [], + }: { + source?: Observable; + types?: string[] | Promise; + } = {} ): jest.Mocked => ({ id, find: jest.fn().mockImplementation((term, options, context) => source), + getSearchableTypes: jest.fn().mockReturnValue(types), }); const expectedResult = (id: string) => expect.objectContaining({ id }); @@ -85,6 +96,9 @@ describe('SearchService', () => { fetchServerResultsMock.mockClear(); fetchServerResultsMock.mockReturnValue(of()); + fetchServerSearchableTypesMock.mockClear(); + fetchServerSearchableTypesMock.mockResolvedValue([]); + getDefaultPreferenceMock.mockClear(); getDefaultPreferenceMock.mockReturnValue('default_pref'); }); @@ -189,7 +203,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -229,22 +243,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [providerResult('A1'), providerResult('A2')], d: [providerResult('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [providerResult('B1')], c: [providerResult('B2'), providerResult('B3')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -272,13 +284,12 @@ describe('SearchService', () => { ); registerResultProvider( - createProvider( - 'A', - hot('a-b-|', { + createProvider('A', { + source: hot('a-b-|', { a: [providerResult('P1')], b: [providerResult('P2')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -302,7 +313,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const aborted$ = hot('----a--|', { a: undefined }); @@ -326,7 +337,7 @@ describe('SearchService', () => { b: [providerResult('2')], c: [providerResult('3')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -346,22 +357,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [providerResult('A1'), providerResult('A2')], d: [providerResult('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [providerResult('B1')], c: [providerResult('B2'), providerResult('B3')], - }) - ) + }), + }) ); const { find } = service.start(startDeps()); @@ -394,7 +403,7 @@ describe('SearchService', () => { url: { path: '/foo', prependBasePath: false }, }); - const provider = createProvider('A', of([resultA, resultB])); + const provider = createProvider('A', { source: of([resultA, resultB]) }); registerResultProvider(provider); const { find } = service.start(startDeps()); @@ -423,7 +432,7 @@ describe('SearchService', () => { a: [providerResult('1')], b: [providerResult('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start(startDeps()); const results = find({ term: 'foobar' }, {}); @@ -438,5 +447,91 @@ describe('SearchService', () => { }); }); }); + + describe('#getSearchableTypes()', () => { + it('returns the types registered by the provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('returns the types registered by the server', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'server-b']); + + service.setup({ + config: createConfig(), + }); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types).toEqual(['server-a', 'server-b']); + }); + + it('merges the types registered by the providers', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-c', 'type-d'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['type-a', 'type-b', 'type-c', 'type-d']); + }); + + it('merges the types registered by the providers and the server', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'server-b']); + + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['server-a', 'server-b', 'type-a', 'type-b']); + }); + + it('removes duplicates', async () => { + fetchServerSearchableTypesMock.mockResolvedValue(['server-a', 'dupe-1']); + + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider1 = createProvider('A', { types: ['type-a', 'dupe-1', 'dupe-2'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-b', 'dupe-2'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start(startDeps()); + + const types = await getSearchableTypes(); + + expect(types.sort()).toEqual(['dupe-1', 'dupe-2', 'server-a', 'type-a', 'type-b']); + }); + }); }); }); diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts index 64bd2fd6c930f..015143d34886f 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -6,6 +6,7 @@ import { merge, Observable, timer, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import { uniq } from 'lodash'; import { duration } from 'moment'; import { i18n } from '@kbn/i18n'; import { HttpStart } from 'src/core/public'; @@ -24,6 +25,7 @@ import { GlobalSearchClientConfigType } from '../config'; import { GlobalSearchFindOptions } from './types'; import { getDefaultPreference } from './utils'; import { fetchServerResults } from './fetch_server_results'; +import { fetchServerSearchableTypes } from './fetch_server_searchable_types'; /** @public */ export interface SearchServiceSetup { @@ -75,6 +77,11 @@ export interface SearchServiceStart { params: GlobalSearchFindParams, options: GlobalSearchFindOptions ): Observable; + + /** + * Returns all the searchable types registered by the underlying result providers. + */ + getSearchableTypes(): Promise; } interface SetupDeps { @@ -96,6 +103,7 @@ export class SearchService { private http?: HttpStart; private maxProviderResults = defaultMaxProviderResults; private licenseChecker?: ILicenseChecker; + private serverTypes?: string[]; setup({ config, maxProviderResults = defaultMaxProviderResults }: SetupDeps): SearchServiceSetup { this.config = config; @@ -118,9 +126,25 @@ export class SearchService { return { find: (params, options) => this.performFind(params, options), + getSearchableTypes: () => this.getSearchableTypes(), }; } + private async getSearchableTypes() { + const providerTypes = ( + await Promise.all( + [...this.providers.values()].map((provider) => provider.getSearchableTypes()) + ) + ).flat(); + + // only need to fetch from server once + if (!this.serverTypes) { + this.serverTypes = await fetchServerSearchableTypes(this.http!); + } + + return uniq([...providerTypes, ...this.serverTypes]); + } + private performFind(params: GlobalSearchFindParams, options: GlobalSearchFindOptions) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { diff --git a/x-pack/plugins/global_search/public/types.ts b/x-pack/plugins/global_search/public/types.ts index 2707a2fded222..7235347d4aa38 100644 --- a/x-pack/plugins/global_search/public/types.ts +++ b/x-pack/plugins/global_search/public/types.ts @@ -13,7 +13,7 @@ import { import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; -export type GlobalSearchPluginStart = Pick; +export type GlobalSearchPluginStart = Pick; /** * GlobalSearch result provider, to be registered using the {@link GlobalSearchPluginSetup | global search API} @@ -44,4 +44,10 @@ export interface GlobalSearchResultProvider { search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions ): Observable; + + /** + * Method that should return all the possible {@link GlobalSearchProviderResult.type | type} of results that + * this provider can return. + */ + getSearchableTypes: () => string[] | Promise; } diff --git a/x-pack/plugins/global_search/server/mocks.ts b/x-pack/plugins/global_search/server/mocks.ts index e7c133edf95c8..88be7f6e861a1 100644 --- a/x-pack/plugins/global_search/server/mocks.ts +++ b/x-pack/plugins/global_search/server/mocks.ts @@ -26,12 +26,14 @@ const createStartMock = (): jest.Mocked => { return { find: searchMock.find, + getSearchableTypes: searchMock.getSearchableTypes, }; }; const createRouteHandlerContextMock = (): jest.Mocked => { const handlerContextMock = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; handlerContextMock.find.mockReturnValue(of([])); diff --git a/x-pack/plugins/global_search/server/plugin.ts b/x-pack/plugins/global_search/server/plugin.ts index 87e7f96b34c0c..9d6844dde50f0 100644 --- a/x-pack/plugins/global_search/server/plugin.ts +++ b/x-pack/plugins/global_search/server/plugin.ts @@ -59,6 +59,7 @@ export class GlobalSearchPlugin core.http.registerRouteHandlerContext('globalSearch', (_, req) => { return { find: (term, options) => this.searchServiceStart!.find(term, options, req), + getSearchableTypes: () => this.searchServiceStart!.getSearchableTypes(req), }; }); @@ -75,6 +76,7 @@ export class GlobalSearchPlugin }); return { find: this.searchServiceStart.find, + getSearchableTypes: this.searchServiceStart.getSearchableTypes, }; } diff --git a/x-pack/plugins/global_search/server/routes/get_searchable_types.ts b/x-pack/plugins/global_search/server/routes/get_searchable_types.ts new file mode 100644 index 0000000000000..f9cc69e4a28ae --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/get_searchable_types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; + +export const registerInternalSearchableTypesRoute = (router: IRouter) => { + router.get( + { + path: '/internal/global_search/searchable_types', + validate: false, + }, + async (ctx, req, res) => { + const types = await ctx.globalSearch!.getSearchableTypes(); + return res.ok({ + body: { + types, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/global_search/server/routes/index.test.ts b/x-pack/plugins/global_search/server/routes/index.test.ts index 64675bc13cb1c..1111f01d13055 100644 --- a/x-pack/plugins/global_search/server/routes/index.test.ts +++ b/x-pack/plugins/global_search/server/routes/index.test.ts @@ -14,7 +14,6 @@ describe('registerRoutes', () => { registerRoutes(router); expect(router.post).toHaveBeenCalledTimes(1); - expect(router.post).toHaveBeenCalledWith( expect.objectContaining({ path: '/internal/global_search/find', @@ -22,7 +21,14 @@ describe('registerRoutes', () => { expect.any(Function) ); - expect(router.get).toHaveBeenCalledTimes(0); + expect(router.get).toHaveBeenCalledTimes(1); + expect(router.get).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/internal/global_search/searchable_types', + }), + expect.any(Function) + ); + expect(router.delete).toHaveBeenCalledTimes(0); expect(router.put).toHaveBeenCalledTimes(0); }); diff --git a/x-pack/plugins/global_search/server/routes/index.ts b/x-pack/plugins/global_search/server/routes/index.ts index 7840b95614993..0eeb443b72b53 100644 --- a/x-pack/plugins/global_search/server/routes/index.ts +++ b/x-pack/plugins/global_search/server/routes/index.ts @@ -6,7 +6,9 @@ import { IRouter } from 'src/core/server'; import { registerInternalFindRoute } from './find'; +import { registerInternalSearchableTypesRoute } from './get_searchable_types'; export const registerRoutes = (router: IRouter) => { registerInternalFindRoute(router); + registerInternalSearchableTypesRoute(router); }; diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts new file mode 100644 index 0000000000000..b3b6862599d6d --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/integration_tests/get_searchable_types.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { setupServer } from '../../../../../../src/core/server/test_utils'; +import { globalSearchPluginMock } from '../../mocks'; +import { registerInternalSearchableTypesRoute } from '../get_searchable_types'; + +type SetupServerReturn = UnwrapPromise>; +const pluginId = Symbol('globalSearch'); + +describe('GET /internal/global_search/searchable_types', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let globalSearchHandlerContext: ReturnType< + typeof globalSearchPluginMock.createRouteHandlerContext + >; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(pluginId)); + + globalSearchHandlerContext = globalSearchPluginMock.createRouteHandlerContext(); + httpSetup.registerRouteHandlerContext( + pluginId, + 'globalSearch', + () => globalSearchHandlerContext + ); + + const router = httpSetup.createRouter('/'); + + registerInternalSearchableTypesRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('calls the handler context with correct parameters', async () => { + await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(globalSearchHandlerContext.getSearchableTypes).toHaveBeenCalledTimes(1); + }); + + it('returns the types returned from the service', async () => { + globalSearchHandlerContext.getSearchableTypes.mockResolvedValue(['type-a', 'type-b']); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(response.body).toEqual({ + types: ['type-a', 'type-b'], + }); + }); + + it('returns the default error when the observable throws any other error', async () => { + globalSearchHandlerContext.getSearchableTypes.mockRejectedValue(new Error()); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/searchable_types') + .expect(200); + + expect(response.body).toEqual( + expect.objectContaining({ + message: 'An internal server error occurred.', + statusCode: 500, + }) + ); + }); +}); diff --git a/x-pack/plugins/global_search/server/services/search_service.mock.ts b/x-pack/plugins/global_search/server/services/search_service.mock.ts index eca69148288b9..0aa65e39f026c 100644 --- a/x-pack/plugins/global_search/server/services/search_service.mock.ts +++ b/x-pack/plugins/global_search/server/services/search_service.mock.ts @@ -7,17 +7,21 @@ import { SearchServiceSetup, SearchServiceStart } from './search_service'; import { of } from 'rxjs'; -const createSetupMock = (): jest.Mocked => { - return { +const createSetupMock = () => { + const mock: jest.Mocked = { registerResultProvider: jest.fn(), }; + + return mock; }; -const createStartMock = (): jest.Mocked => { - const mock = { +const createStartMock = () => { + const mock: jest.Mocked = { find: jest.fn(), + getSearchableTypes: jest.fn(), }; mock.find.mockReturnValue(of({ results: [] })); + mock.getSearchableTypes.mockResolvedValue([]); return mock; }; diff --git a/x-pack/plugins/global_search/server/services/search_service.test.ts b/x-pack/plugins/global_search/server/services/search_service.test.ts index c8d656a524e94..b3e4981b35392 100644 --- a/x-pack/plugins/global_search/server/services/search_service.test.ts +++ b/x-pack/plugins/global_search/server/services/search_service.test.ts @@ -36,10 +36,17 @@ describe('SearchService', () => { const createProvider = ( id: string, - source: Observable = of([]) + { + source = of([]), + types = [], + }: { + source?: Observable; + types?: string[] | Promise; + } = {} ): jest.Mocked => ({ id, find: jest.fn().mockImplementation((term, options, context) => source), + getSearchableTypes: jest.fn().mockReturnValue(types), }); const expectedResult = (id: string) => expect.objectContaining({ id }); @@ -122,7 +129,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -142,22 +149,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [result('A1'), result('A2')], d: [result('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [result('B1')], c: [result('B2'), result('B3')], - }) - ) + }), + }) ); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -183,7 +188,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const aborted$ = hot('----a--|', { a: undefined }); @@ -208,7 +213,7 @@ describe('SearchService', () => { b: [result('2')], c: [result('3')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -229,22 +234,20 @@ describe('SearchService', () => { getTestScheduler().run(({ expectObservable, hot }) => { registerResultProvider( - createProvider( - 'A', - hot('a---d-|', { + createProvider('A', { + source: hot('a---d-|', { a: [result('A1'), result('A2')], d: [result('A3')], - }) - ) + }), + }) ); registerResultProvider( - createProvider( - 'B', - hot('-b-c| ', { + createProvider('B', { + source: hot('-b-c| ', { b: [result('B1')], c: [result('B2'), result('B3')], - }) - ) + }), + }) ); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -278,7 +281,7 @@ describe('SearchService', () => { url: { path: '/foo', prependBasePath: false }, }); - const provider = createProvider('A', of([resultA, resultB])); + const provider = createProvider('A', { source: of([resultA, resultB]) }); registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); @@ -308,7 +311,7 @@ describe('SearchService', () => { a: [result('1')], b: [result('2')], }); - registerResultProvider(createProvider('A', providerResults)); + registerResultProvider(createProvider('A', { source: providerResults })); const { find } = service.start({ core: coreStart, licenseChecker }); const results = find({ term: 'foobar' }, {}, request); @@ -323,5 +326,77 @@ describe('SearchService', () => { }); }); }); + + describe('#getSearchableTypes()', () => { + it('returns the types registered by the provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('supports promises', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider = createProvider('A', { types: Promise.resolve(['type-a', 'type-b']) }); + registerResultProvider(provider); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types).toEqual(['type-a', 'type-b']); + }); + + it('merges the types registered by the providers', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider1 = createProvider('A', { types: ['type-a', 'type-b'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-c', 'type-d'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types.sort()).toEqual(['type-a', 'type-b', 'type-c', 'type-d']); + }); + + it('removes duplicates', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider1 = createProvider('A', { types: ['type-a', 'dupe'] }); + registerResultProvider(provider1); + + const provider2 = createProvider('B', { types: ['type-b', 'dupe'] }); + registerResultProvider(provider2); + + const { getSearchableTypes } = service.start({ core: coreStart, licenseChecker }); + + const types = await getSearchableTypes(request); + + expect(types.sort()).toEqual(['dupe', 'type-a', 'type-b']); + }); + }); }); }); diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts index 9ea62abac704c..88250820861a6 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -6,6 +6,7 @@ import { Observable, timer, merge, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import { uniq } from 'lodash'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; import { @@ -71,6 +72,11 @@ export interface SearchServiceStart { options: GlobalSearchFindOptions, request: KibanaRequest ): Observable; + + /** + * Returns all the searchable types registered by the underlying result providers. + */ + getSearchableTypes(request: KibanaRequest): Promise; } interface SetupDeps { @@ -119,9 +125,20 @@ export class SearchService { this.contextFactory = getContextFactory(core); return { find: (params, options, request) => this.performFind(params, options, request), + getSearchableTypes: (request) => this.getSearchableTypes(request), }; } + private async getSearchableTypes(request: KibanaRequest) { + const context = this.contextFactory!(request); + const allTypes = ( + await Promise.all( + [...this.providers.values()].map((provider) => provider.getSearchableTypes(context)) + ) + ).flat(); + return uniq(allTypes); + } + private performFind( params: GlobalSearchFindParams, options: GlobalSearchFindOptions, diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts index 0878a965ea8c3..48c40fdb66e13 100644 --- a/x-pack/plugins/global_search/server/types.ts +++ b/x-pack/plugins/global_search/server/types.ts @@ -22,7 +22,7 @@ import { import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; -export type GlobalSearchPluginStart = Pick; +export type GlobalSearchPluginStart = Pick; /** * globalSearch route handler context. @@ -37,6 +37,10 @@ export interface RouteHandlerGlobalSearchContext { params: GlobalSearchFindParams, options: GlobalSearchFindOptions ): Observable; + /** + * See {@link SearchServiceStart.getSearchableTypes | the getSearchableTypes API} + */ + getSearchableTypes: () => Promise; } /** @@ -114,4 +118,10 @@ export interface GlobalSearchResultProvider { options: GlobalSearchProviderFindOptions, context: GlobalSearchProviderContext ): Observable; + + /** + * Method that should return all the possible {@link GlobalSearchProviderResult.type | type} of results that + * this provider can return. + */ + getSearchableTypes: (context: GlobalSearchProviderContext) => string[] | Promise; } diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index 5ba00c293d213..1ed011d3cc3b1 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -11,8 +11,8 @@ import { of, BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { mountWithIntl } from '@kbn/test/jest'; import { applicationServiceMock } from '../../../../../src/core/public/mocks'; -import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { globalSearchPluginMock } from '../../../global_search/public/mocks'; +import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { SearchBar } from './search_bar'; type Result = { id: string; type: string } | string; @@ -86,7 +86,7 @@ describe('SearchBar', () => { component = mountWithIntl( { it('supports keyboard shortcuts', () => { mountWithIntl( { component = mountWithIntl( void; taggingApi?: SavedObjectTaggingPluginStart; @@ -43,16 +45,19 @@ interface Props { darkMode: boolean; } -const clearField = (field: HTMLInputElement) => { +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + +const setFieldValue = (field: HTMLInputElement, value: string) => { const nativeInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); const nativeInputValueSetter = nativeInputValue ? nativeInputValue.set : undefined; if (nativeInputValueSetter) { - nativeInputValueSetter.call(field, ''); + nativeInputValueSetter.call(field, value); } - field.dispatchEvent(new Event('change')); }; +const clearField = (field: HTMLInputElement) => setFieldValue(field, ''); + const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' '); const blurEvent = new FocusEvent('blur'); @@ -92,6 +97,19 @@ const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewi return option; }; +const suggestionToOption = (suggestion: SearchSuggestion): EuiSelectableTemplateSitewideOption => { + const { key, label, description, icon, suggestedSearch } = suggestion; + return { + key, + label, + type: '__suggestion__', + icon: { type: icon }, + suggestion: suggestedSearch, + meta: [{ text: description }], + 'data-test-subj': `nav-search-option`, + }; +}; + export function SearchBar({ globalSearch, taggingApi, @@ -105,16 +123,34 @@ export function SearchBar({ const [searchRef, setSearchRef] = useState(null); const [buttonRef, setButtonRef] = useState(null); const searchSubscription = useRef(null); - const [options, _setOptions] = useState([] as EuiSelectableTemplateSitewideOption[]); - const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; + const [options, _setOptions] = useState([]); + const [searchableTypes, setSearchableTypes] = useState([]); + + useEffect(() => { + const fetch = async () => { + const types = await globalSearch.getSearchableTypes(); + setSearchableTypes(types); + }; + fetch(); + }, [globalSearch]); + + const loadSuggestions = useCallback( + (searchTerm: string) => { + return getSuggestions({ + searchTerm, + searchableTypes, + tagCache: taggingApi?.cache, + }); + }, + [taggingApi, searchableTypes] + ); const setOptions = useCallback( - (_options: GlobalSearchResult[]) => { + (_options: GlobalSearchResult[], suggestions: SearchSuggestion[]) => { if (!isMounted()) { return; } - - _setOptions(_options.map(resultToOption)); + _setOptions([...suggestions.map(suggestionToOption), ..._options.map(resultToOption)]); }, [isMounted, _setOptions] ); @@ -127,7 +163,9 @@ export function SearchBar({ searchSubscription.current = null; } - let arr: GlobalSearchResult[] = []; + const suggestions = loadSuggestions(searchValue); + + let aggregatedResults: GlobalSearchResult[] = []; if (searchValue.length !== 0) { trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); } @@ -145,20 +183,20 @@ export function SearchBar({ tags: tagIds, }; - searchSubscription.current = globalSearch(searchParams, {}).subscribe({ + searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { - arr = [...results, ...arr].sort(sortByScore); - setOptions(arr); + aggregatedResults = [...results, ...aggregatedResults].sort(sortByScore); + setOptions(aggregatedResults, suggestions); return; } // if searchbar is empty, filter to only applications and sort alphabetically results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); - arr = [...results, ...arr].sort(sortByTitle); + aggregatedResults = [...results, ...aggregatedResults].sort(sortByTitle); - setOptions(arr); + setOptions(aggregatedResults, suggestions); }, error: () => { // Not doing anything on error right now because it'll either just show the previous @@ -169,7 +207,7 @@ export function SearchBar({ }); }, 350, - [searchValue] + [searchValue, loadSuggestions] ); const onKeyDown = (event: KeyboardEvent) => { @@ -191,7 +229,15 @@ export function SearchBar({ } // @ts-ignore - ts error is "union type is too complex to express" - const { url, type } = selected; + const { url, type, suggestion } = selected; + + // if the type is a suggestion, we change the query on the input and trigger a new search + // by setting the searchValue (only setting the field value does not trigger a search) + if (type === '__suggestion__') { + setFieldValue(searchRef!, suggestion); + setSearchValue(suggestion); + return; + } // errors in tracking should not prevent selection behavior try { diff --git a/x-pack/plugins/global_search_bar/public/plugin.tsx b/x-pack/plugins/global_search_bar/public/plugin.tsx index 0d17bf4612737..80111e7746a75 100644 --- a/x-pack/plugins/global_search_bar/public/plugin.tsx +++ b/x-pack/plugins/global_search_bar/public/plugin.tsx @@ -70,7 +70,7 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { ReactDOM.render( = {}): Tag => ({ + id: 'tag-id', + name: 'some-tag', + description: 'Some tag', + color: '#FF00CC', + ...parts, +}); + +describe('getSuggestions', () => { + let tagCache: ReturnType; + const searchableTypes = ['application', 'dashboard', 'maps']; + + beforeEach(() => { + tagCache = taggingApiMock.createCache(); + + tagCache.getState.mockReturnValue([ + createTag({ + id: 'basic', + name: 'normal', + }), + createTag({ + id: 'caps', + name: 'BAR', + }), + createTag({ + id: 'whitespace', + name: 'white space', + }), + ]); + }); + + describe('tag suggestion', () => { + it('returns a suggestion when matching the name of a tag', () => { + const suggestions = getSuggestions({ + searchTerm: 'normal', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: normal', + suggestedSearch: 'tag:normal', + }) + ); + }); + it('ignores leading or trailing spaces a suggestion when matching the name of a tag', () => { + const suggestions = getSuggestions({ + searchTerm: ' normal ', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: normal', + suggestedSearch: 'tag:normal', + }) + ); + }); + it('does not return suggestions when partially matching', () => { + const suggestions = getSuggestions({ + searchTerm: 'norm', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(0); + }); + it('ignores the case when matching the tag', () => { + const suggestions = getSuggestions({ + searchTerm: 'baR', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: BAR', + suggestedSearch: 'tag:BAR', + }) + ); + }); + it('escapes the name in the query when containing whitespaces', () => { + const suggestions = getSuggestions({ + searchTerm: 'white space', + tagCache, + searchableTypes: [], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'tag: white space', + suggestedSearch: 'tag:"white space"', + }) + ); + }); + }); + + describe('type suggestion', () => { + it('returns a suggestion when matching a searchable type', () => { + const suggestions = getSuggestions({ + searchTerm: 'application', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: application', + suggestedSearch: 'type:application', + }) + ); + }); + it('ignores leading or trailing spaces in the search term', () => { + const suggestions = getSuggestions({ + searchTerm: ' application ', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: application', + suggestedSearch: 'type:application', + }) + ); + }); + it('does not return suggestions when partially matching', () => { + const suggestions = getSuggestions({ + searchTerm: 'appl', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(0); + }); + it('ignores the case when matching the type', () => { + const suggestions = getSuggestions({ + searchTerm: 'DASHboard', + tagCache, + searchableTypes, + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + label: 'type: dashboard', + suggestedSearch: 'type:dashboard', + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts b/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts new file mode 100644 index 0000000000000..c097e365045af --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/suggestions/get_suggestions.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ITagsCache } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; + +interface GetSuggestionOptions { + searchTerm: string; + searchableTypes: string[]; + tagCache?: ITagsCache; +} + +export interface SearchSuggestion { + key: string; + label: string; + description: string; + icon: string; + suggestedSearch: string; +} + +export const getSuggestions = ({ + searchTerm, + searchableTypes, + tagCache, +}: GetSuggestionOptions): SearchSuggestion[] => { + const results: SearchSuggestion[] = []; + const suggestionTerm = searchTerm.trim(); + + const matchingType = findIgnoreCase(searchableTypes, suggestionTerm); + if (matchingType) { + const suggestedSearch = escapeIfWhiteSpaces(matchingType); + results.push({ + key: '__type__suggestion__', + label: `type: ${matchingType}`, + icon: 'filter', + description: i18n.translate('xpack.globalSearchBar.suggestions.filterByTypeLabel', { + defaultMessage: 'Filter by type', + }), + suggestedSearch: `type:${suggestedSearch}`, + }); + } + + if (tagCache && searchTerm) { + const matchingTag = tagCache + .getState() + .find((tag) => equalsIgnoreCase(tag.name, suggestionTerm)); + if (matchingTag) { + const suggestedSearch = escapeIfWhiteSpaces(matchingTag.name); + results.push({ + key: '__tag__suggestion__', + label: `tag: ${matchingTag.name}`, + icon: 'tag', + description: i18n.translate('xpack.globalSearchBar.suggestions.filterByTagLabel', { + defaultMessage: 'Filter by tag name', + }), + suggestedSearch: `tag:${suggestedSearch}`, + }); + } + } + + return results; +}; + +const findIgnoreCase = (array: string[], target: string) => { + for (const item of array) { + if (equalsIgnoreCase(item, target)) { + return item; + } + } + return undefined; +}; + +const equalsIgnoreCase = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + +const escapeIfWhiteSpaces = (term: string) => { + if (/\s/g.test(term)) { + return `"${term}"`; + } + return term; +}; diff --git a/x-pack/plugins/global_search_bar/public/suggestions/index.ts b/x-pack/plugins/global_search_bar/public/suggestions/index.ts new file mode 100644 index 0000000000000..aa1402a93692b --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/suggestions/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getSuggestions, SearchSuggestion } from './get_suggestions'; diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.ts index 7beed42de4c4f..dadcf626ace4a 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.test.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -71,205 +71,228 @@ describe('applicationResultProvider', () => { expect(provider.id).toBe('application'); }); - it('calls `getAppResults` with the term and the list of apps', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - createApp({ id: 'app3', title: 'App 3' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - - await provider.find({ term: 'term' }, defaultOption).toPromise(); - - expect(getAppResultsMock).toHaveBeenCalledTimes(1); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [ - expectApp('app1'), - expectApp('app2'), - expectApp('app3'), - ]); - }); - - it('calls `getAppResults` when filtering by type with `application` included', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - - await provider - .find({ term: 'term', types: ['dashboard', 'application'] }, defaultOption) - .toPromise(); + describe('#find', () => { + it('calls `getAppResults` with the term and the list of apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + expectApp('app3'), + ]); + }); - expect(getAppResultsMock).toHaveBeenCalledTimes(1); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1'), expectApp('app2')]); - }); + it('calls `getAppResults` when filtering by type with `application` included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider + .find({ term: 'term', types: ['dashboard', 'application'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [ + expectApp('app1'), + expectApp('app2'), + ]); + }); - it('does not call `getAppResults` and return no results when filtering by type with `application` not included', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - createApp({ id: 'app3', title: 'App 3' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); + it('does not call `getAppResults` and return no results when filtering by type with `application` not included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const results = await provider + .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); - const results = await provider - .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) - .toPromise(); + it('does not call `getAppResults` and returns no results when filtering by tag', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const results = await provider + .find({ term: 'term', tags: ['some-tag-id'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); - expect(getAppResultsMock).not.toHaveBeenCalled(); - expect(results).toEqual([]); - }); + it('ignores inaccessible apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'disabled', title: 'disabled', status: AppStatus.inaccessible }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - it('does not call `getAppResults` and returns no results when filtering by tag', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'app2', title: 'App 2' }), - createApp({ id: 'app3', title: 'App 3' }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); + it('ignores apps with non-visible navlink', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1', navLinkStatus: AppNavLinkStatus.visible }), + createApp({ + id: 'disabled', + title: 'disabled', + navLinkStatus: AppNavLinkStatus.disabled, + }), + createApp({ id: 'hidden', title: 'hidden', navLinkStatus: AppNavLinkStatus.hidden }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - const results = await provider - .find({ term: 'term', tags: ['some-tag-id'] }, defaultOption) - .toPromise(); + it('ignores chromeless apps', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), + ]) + ); - expect(getAppResultsMock).not.toHaveBeenCalled(); - expect(results).toEqual([]); - }); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find({ term: 'term' }, defaultOption).toPromise(); - it('ignores inaccessible apps', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'disabled', title: 'disabled', status: AppStatus.inaccessible }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find({ term: 'term' }, defaultOption).toPromise(); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); - }); + it('sorts the results returned by `getAppResults`', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); + + const provider = createApplicationResultProvider(Promise.resolve(application)); + const results = await provider.find({ term: 'term' }, defaultOption).toPromise(); + + expect(results).toEqual([ + expectResult('r100'), + expectResult('r75'), + expectResult('r60'), + expectResult('r50'), + ]); + }); - it('ignores apps with non-visible navlink', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1', navLinkStatus: AppNavLinkStatus.visible }), - createApp({ id: 'disabled', title: 'disabled', navLinkStatus: AppNavLinkStatus.disabled }), - createApp({ id: 'hidden', title: 'hidden', navLinkStatus: AppNavLinkStatus.hidden }), - ]) - ); - const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find({ term: 'term' }, defaultOption).toPromise(); + it('only returns the highest `maxResults` results', async () => { + getAppResultsMock.mockReturnValue([ + createResult({ id: 'r60', score: 60 }), + createResult({ id: 'r100', score: 100 }), + createResult({ id: 'r50', score: 50 }), + createResult({ id: 'r75', score: 75 }), + ]); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); - }); + const provider = createApplicationResultProvider(Promise.resolve(application)); - it('ignores chromeless apps', async () => { - application.applications$ = of( - createAppMap([ - createApp({ id: 'app1', title: 'App 1' }), - createApp({ id: 'chromeless', title: 'chromeless', chromeless: true }), - ]) - ); + const options = { + ...defaultOption, + maxResults: 2, + }; + const results = await provider.find({ term: 'term' }, options).toPromise(); - const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find({ term: 'term' }, defaultOption).toPromise(); + expect(results).toEqual([expectResult('r100'), expectResult('r75')]); + }); - expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); - }); + it('only emits once, even if `application$` emits multiple times', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); - it('sorts the results returned by `getAppResults`', async () => { - getAppResultsMock.mockReturnValue([ - createResult({ id: 'r60', score: 60 }), - createResult({ id: 'r100', score: 100 }), - createResult({ id: 'r50', score: 50 }), - createResult({ id: 'r75', score: 75 }), - ]); + application.applications$ = hot('--a---b', { a: appMap, b: appMap }); - const provider = createApplicationResultProvider(Promise.resolve(application)); - const results = await provider.find({ term: 'term' }, defaultOption).toPromise(); - - expect(results).toEqual([ - expectResult('r100'), - expectResult('r75'), - expectResult('r60'), - expectResult('r50'), - ]); - }); + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { + a: application, + }) as unknown) as Promise; - it('only returns the highest `maxResults` results', async () => { - getAppResultsMock.mockReturnValue([ - createResult({ id: 'r60', score: 60 }), - createResult({ id: 'r100', score: 100 }), - createResult({ id: 'r50', score: 50 }), - createResult({ id: 'r75', score: 75 }), - ]); + const provider = createApplicationResultProvider(applicationPromise); - const provider = createApplicationResultProvider(Promise.resolve(application)); + const options = { + ...defaultOption, + aborted$: hot('|'), + }; - const options = { - ...defaultOption, - maxResults: 2, - }; - const results = await provider.find({ term: 'term' }, options).toPromise(); + const resultObs = provider.find({ term: 'term' }, options); - expect(results).toEqual([expectResult('r100'), expectResult('r75')]); - }); + expectObservable(resultObs).toBe('--(a|)', { a: [] }); + }); + }); - it('only emits once, even if `application$` emits multiple times', () => { - getTestScheduler().run(({ hot, expectObservable }) => { - const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); - application.applications$ = hot('--a---b', { a: appMap, b: appMap }); + application.applications$ = hot('---a', { a: appMap, b: appMap }); - // test scheduler doesnt play well with promises. need to workaround by passing - // an observable instead. Behavior with promise is asserted in previous tests of the suite - const applicationPromise = (hot('a', { - a: application, - }) as unknown) as Promise; + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + const applicationPromise = (hot('a', { + a: application, + }) as unknown) as Promise; - const provider = createApplicationResultProvider(applicationPromise); + const provider = createApplicationResultProvider(applicationPromise); - const options = { - ...defaultOption, - aborted$: hot('|'), - }; + const options = { + ...defaultOption, + aborted$: hot('-(a|)', { a: undefined }), + }; - const resultObs = provider.find({ term: 'term' }, options); + const resultObs = provider.find({ term: 'term' }, options); - expectObservable(resultObs).toBe('--(a|)', { a: [] }); + expectObservable(resultObs).toBe('-|'); + }); }); }); - it('only emits results until `aborted$` emits', () => { - getTestScheduler().run(({ hot, expectObservable }) => { - const appMap = createAppMap([createApp({ id: 'app1', title: 'App 1' })]); - - application.applications$ = hot('---a', { a: appMap, b: appMap }); - - // test scheduler doesnt play well with promises. need to workaround by passing - // an observable instead. Behavior with promise is asserted in previous tests of the suite - const applicationPromise = (hot('a', { - a: application, - }) as unknown) as Promise; - - const provider = createApplicationResultProvider(applicationPromise); - - const options = { - ...defaultOption, - aborted$: hot('-(a|)', { a: undefined }), - }; - - const resultObs = provider.find({ term: 'term' }, options); - - expectObservable(resultObs).toBe('-|'); + describe('#getSearchableTypes', () => { + it('returns only the `application` type', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + expect(await provider.getSearchableTypes()).toEqual(['application']); }); }); }); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts index fd6eb0dc1878b..5b4c58161c0ae 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -10,6 +10,8 @@ import { ApplicationStart } from 'src/core/public'; import { GlobalSearchResultProvider } from '../../../global_search/public'; import { getAppResults } from './get_app_results'; +const applicationType = 'application'; + export const createApplicationResultProvider = ( applicationPromise: Promise ): GlobalSearchResultProvider => { @@ -27,7 +29,7 @@ export const createApplicationResultProvider = ( return { id: 'application', find: ({ term, types, tags }, { aborted$, maxResults }) => { - if (tags || (types && !types.includes('application'))) { + if (tags || (types && !types.includes(applicationType))) { return of([]); } return searchableApps$.pipe( @@ -39,5 +41,6 @@ export const createApplicationResultProvider = ( }) ); }, + getSearchableTypes: () => [applicationType], }; }; diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index da9276278dbbf..5d24b33f2619e 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -115,117 +115,127 @@ describe('savedObjectsResultProvider', () => { expect(provider.id).toBe('savedObjects'); }); - it('calls `savedObjectClient.find` with the correct parameters', async () => { - await provider.find({ term: 'term' }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title', 'description'], - type: ['typeA', 'typeB'], + describe('#find()', () => { + it('calls `savedObjectClient.find` with the correct parameters', async () => { + await provider.find({ term: 'term' }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title', 'description'], + type: ['typeA', 'typeB'], + }); }); - }); - it('filters searchable types depending on the `types` parameter', async () => { - await provider.find({ term: 'term', types: ['typeA'] }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title'], - type: ['typeA'], + it('filters searchable types depending on the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['typeA'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); }); - }); - it('ignore the case for the `types` parameter', async () => { - await provider.find({ term: 'term', types: ['TyPEa'] }, defaultOption, context).toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title'], - type: ['typeA'], + it('ignore the case for the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['TyPEa'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); }); - }); - it('calls `savedObjectClient.find` with the correct references when the `tags` option is set', async () => { - await provider - .find({ term: 'term', tags: ['tag-id-1', 'tag-id-2'] }, defaultOption, context) - .toPromise(); - - expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); - expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ - page: 1, - perPage: defaultOption.maxResults, - search: 'term*', - preference: 'pref', - searchFields: ['title', 'description'], - hasReference: [ - { type: 'tag', id: 'tag-id-1' }, - { type: 'tag', id: 'tag-id-2' }, - ], - type: ['typeA', 'typeB'], + it('calls `savedObjectClient.find` with the correct references when the `tags` option is set', async () => { + await provider + .find({ term: 'term', tags: ['tag-id-1', 'tag-id-2'] }, defaultOption, context) + .toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title', 'description'], + hasReference: [ + { type: 'tag', id: 'tag-id-1' }, + { type: 'tag', id: 'tag-id-2' }, + ], + type: ['typeA', 'typeB'], + }); }); - }); - it('does not call `savedObjectClient.find` if all params are empty', async () => { - const results = await provider.find({}, defaultOption, context).pipe(toArray()).toPromise(); + it('does not call `savedObjectClient.find` if all params are empty', async () => { + const results = await provider.find({}, defaultOption, context).pipe(toArray()).toPromise(); - expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); - expect(results).toEqual([[]]); - }); + expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); + expect(results).toEqual([[]]); + }); - it('converts the saved objects to results', async () => { - context.core.savedObjects.client.find.mockResolvedValue( - createFindResponse([ - createObject({ id: 'resultA', type: 'typeA', score: 50 }, { title: 'titleA' }), - createObject({ id: 'resultB', type: 'typeB', score: 78 }, { description: 'titleB' }), - ]) - ); + it('converts the saved objects to results', async () => { + context.core.savedObjects.client.find.mockResolvedValue( + createFindResponse([ + createObject({ id: 'resultA', type: 'typeA', score: 50 }, { title: 'titleA' }), + createObject({ id: 'resultB', type: 'typeB', score: 78 }, { description: 'titleB' }), + ]) + ); - const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); - expect(results).toEqual([ - { - id: 'resultA', - title: 'titleA', - type: 'typeA', - url: '/type-a/resultA', - score: 50, - }, - { - id: 'resultB', - title: 'titleB', - type: 'typeB', - url: '/type-b/resultB', - score: 78, - }, - ]); - }); + const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); + expect(results).toEqual([ + { + id: 'resultA', + title: 'titleA', + type: 'typeA', + url: '/type-a/resultA', + score: 50, + }, + { + id: 'resultB', + title: 'titleB', + type: 'typeB', + url: '/type-b/resultB', + score: 78, + }, + ]); + }); - it('only emits results until `aborted$` emits', () => { - getTestScheduler().run(({ hot, expectObservable }) => { - // test scheduler doesnt play well with promises. need to workaround by passing - // an observable instead. Behavior with promise is asserted in previous tests of the suite - context.core.savedObjects.client.find.mockReturnValue( - hot('---a', { a: createFindResponse([]) }) as any - ); + it('only emits results until `aborted$` emits', () => { + getTestScheduler().run(({ hot, expectObservable }) => { + // test scheduler doesnt play well with promises. need to workaround by passing + // an observable instead. Behavior with promise is asserted in previous tests of the suite + context.core.savedObjects.client.find.mockReturnValue( + hot('---a', { a: createFindResponse([]) }) as any + ); + + const resultObs = provider.find( + { term: 'term' }, + { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, + context + ); + + expectObservable(resultObs).toBe('-|'); + }); + }); + }); - const resultObs = provider.find( - { term: 'term' }, - { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, - context - ); + describe('#getSearchableTypes', () => { + it('returns the searchable saved object types', async () => { + const types = await provider.getSearchableTypes(context); - expectObservable(resultObs).toBe('-|'); + expect(types.sort()).toEqual(['typeA', 'typeB']); }); }); }); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index 3e2c42e7896fd..489e8f71c2d53 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -6,7 +6,7 @@ import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; -import { SavedObjectsFindOptionsReference } from 'src/core/server'; +import { SavedObjectsFindOptionsReference, ISavedObjectTypeRegistry } from 'src/core/server'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; @@ -23,10 +23,7 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = savedObjects: { client, typeRegistry }, } = core; - const searchableTypes = typeRegistry - .getVisibleTypes() - .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) - .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const searchableTypes = getSearchableTypes(typeRegistry, types); const searchFields = uniq( searchableTypes.map((type) => type.management!.defaultSearchField!) @@ -51,9 +48,21 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = map(([res, cap]) => mapToResults(res.saved_objects, typeRegistry, cap)) ); }, + getSearchableTypes: ({ core }) => { + const { + savedObjects: { typeRegistry }, + } = core; + return getSearchableTypes(typeRegistry).map((type) => type.name); + }, }; }; +const getSearchableTypes = (typeRegistry: ISavedObjectTypeRegistry, types?: string[]) => + typeRegistry + .getVisibleTypes() + .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) + .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const uniq = (values: T[]): T[] => [...new Set(values)]; const includeIgnoreCase = (list: string[], item: string) => diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 65952e81ae0ff..32964ab2ce84d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -324,7 +324,7 @@ describe('edit policy', () => { }); }); describe('hot phase', () => { - test('should show errors when trying to save with no max size and no max age', async () => { + test('should show errors when trying to save with no max size, no max age and no max docs', async () => { const rendered = mountWithIntl(component); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeFalsy(); await setPolicyName(rendered, 'mypolicy'); @@ -338,6 +338,11 @@ describe('edit policy', () => { maxAgeInput.simulate('change', { target: { value: '' } }); }); waitForFormLibValidation(rendered); + const maxDocsInput = findTestSubject(rendered, 'hot-selectedMaxDocuments'); + await act(async () => { + maxDocsInput.simulate('change', { target: { value: '' } }); + }); + waitForFormLibValidation(rendered); await save(rendered); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeTruthy(); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index e86bbd9e747bc..5ce4fae596e8e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -24,7 +24,7 @@ import { useFormData, UseField, SelectField, NumericField } from '../../../../.. import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION, useConfigurationIssues } from '../../../form'; +import { ROLLOVER_EMPTY_VALIDATION } from '../../../form'; import { useEditPolicyContext } from '../../../edit_policy_context'; @@ -51,8 +51,6 @@ export const HotPhase: FunctionComponent = () => { const isRolloverEnabled = get(formData, useRolloverPath); const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); - const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); - return ( <> { {(field) => { const showErrorCallout = field.errors.some( - (e) => e.validationType === ROLLOVER_EMPTY_VALIDATION + (e) => e.code === ROLLOVER_EMPTY_VALIDATION ); if (showErrorCallout !== showEmptyRolloverFieldsError) { setShowEmptyRolloverFieldsError(showErrorCallout); @@ -236,8 +234,8 @@ export const HotPhase: FunctionComponent = () => { {isRolloverEnabled && ( <> + {} {license.canUseSearchableSnapshot() && } - {!isUsingSearchableSnapshotInHotPhase && } )} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index fa9def6864be0..6485122771a46 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -8,6 +8,9 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../../../shared_imports'; import { defaultSetPriority, defaultPhaseIndexPriority } from '../../../constants'; +import { ROLLOVER_FORM_PATHS } from '../constants'; + +const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); import { FormInternal } from '../types'; @@ -127,6 +130,7 @@ export const schema: FormSchema = { validator: ifExistsNumberGreaterThanZero, }, ], + fieldsToValidateOnChange: rolloverFormPaths, }, max_docs: { label: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.maximumDocumentsLabel', { @@ -141,6 +145,7 @@ export const schema: FormSchema = { }, ], serializer: serializers.stringToNumber, + fieldsToValidateOnChange: rolloverFormPaths, }, max_size: { label: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.maximumIndexSizeLabel', { @@ -154,6 +159,7 @@ export const schema: FormSchema = { validator: ifExistsNumberGreaterThanZero, }, ], + fieldsToValidateOnChange: rolloverFormPaths, }, }, forcemerge: { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts index f2e26a552efc9..a5d7d68d21915 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts @@ -56,33 +56,31 @@ export const ROLLOVER_EMPTY_VALIDATION = 'ROLLOVER_EMPTY_VALIDATION'; * This validator checks that and updates form values by setting errors states imperatively to * indicate this error state. */ -export const rolloverThresholdsValidator: ValidationFunc = ({ form }) => { +export const rolloverThresholdsValidator: ValidationFunc = ({ form, path }) => { const fields = form.getFields(); if ( !( - fields[ROLLOVER_FORM_PATHS.maxAge].value || - fields[ROLLOVER_FORM_PATHS.maxDocs].value || - fields[ROLLOVER_FORM_PATHS.maxSize].value + fields[ROLLOVER_FORM_PATHS.maxAge]?.value || + fields[ROLLOVER_FORM_PATHS.maxDocs]?.value || + fields[ROLLOVER_FORM_PATHS.maxSize]?.value ) ) { - fields[ROLLOVER_FORM_PATHS.maxAge].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + if (path === ROLLOVER_FORM_PATHS.maxAge) { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumAgeRequiredMessage, - }, - ]); - fields[ROLLOVER_FORM_PATHS.maxDocs].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + }; + } else if (path === ROLLOVER_FORM_PATHS.maxDocs) { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumDocumentsRequiredMessage, - }, - ]); - fields[ROLLOVER_FORM_PATHS.maxSize].setErrors([ - { - validationType: ROLLOVER_EMPTY_VALIDATION, + }; + } else { + return { + code: ROLLOVER_EMPTY_VALIDATION, message: i18nTexts.editPolicy.errors.maximumSizeRequiredMessage, - }, - ]); + }; + } } else { fields[ROLLOVER_FORM_PATHS.maxAge].clearErrors(ROLLOVER_EMPTY_VALIDATION); fields[ROLLOVER_FORM_PATHS.maxDocs].clearErrors(ROLLOVER_EMPTY_VALIDATION); diff --git a/x-pack/plugins/infra/public/apps/common_providers.tsx b/x-pack/plugins/infra/public/apps/common_providers.tsx index fc82f4bf6cb00..e66c54745ca51 100644 --- a/x-pack/plugins/infra/public/apps/common_providers.tsx +++ b/x-pack/plugins/infra/public/apps/common_providers.tsx @@ -5,7 +5,7 @@ */ import { ApolloClient } from 'apollo-client'; -import { CoreStart } from 'kibana/public'; +import { AppMountParameters, CoreStart } from 'kibana/public'; import React, { useMemo } from 'react'; import { useUiSetting$ } from '../../../../../src/plugins/kibana_react/public'; import { EuiThemeProvider } from '../../../observability/public'; @@ -13,20 +13,24 @@ import { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions import { createKibanaContextForPlugin } from '../hooks/use_kibana'; import { InfraClientStartDeps } from '../types'; import { ApolloClientContext } from '../utils/apollo_context'; +import { HeaderActionMenuProvider } from '../utils/header_action_menu_provider'; import { NavigationWarningPromptProvider } from '../utils/navigation_warning_prompt'; import { TriggersActionsProvider } from '../utils/triggers_actions_context'; export const CommonInfraProviders: React.FC<{ apolloClient: ApolloClient<{}>; triggersActionsUI: TriggersAndActionsUIPublicPluginStart; -}> = ({ apolloClient, children, triggersActionsUI }) => { + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; +}> = ({ apolloClient, children, triggersActionsUI, setHeaderActionMenu }) => { const [darkMode] = useUiSetting$('theme:darkMode'); return ( - {children} + + {children} + diff --git a/x-pack/plugins/infra/public/apps/logs_app.tsx b/x-pack/plugins/infra/public/apps/logs_app.tsx index b6b171fcb4727..666ea02693873 100644 --- a/x-pack/plugins/infra/public/apps/logs_app.tsx +++ b/x-pack/plugins/infra/public/apps/logs_app.tsx @@ -23,14 +23,20 @@ import { prepareMountElement } from './common_styles'; export const renderApp = ( core: CoreStart, plugins: InfraClientStartDeps, - { element, history }: AppMountParameters + { element, history, setHeaderActionMenu }: AppMountParameters ) => { const apolloClient = createApolloClient(core.http.fetch); prepareMountElement(element); ReactDOM.render( - , + , element ); @@ -44,7 +50,8 @@ const LogsApp: React.FC<{ core: CoreStart; history: History; plugins: InfraClientStartDeps; -}> = ({ apolloClient, core, history, plugins }) => { + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; +}> = ({ apolloClient, core, history, plugins, setHeaderActionMenu }) => { const uiCapabilities = core.application.capabilities; return ( @@ -52,6 +59,7 @@ const LogsApp: React.FC<{ diff --git a/x-pack/plugins/infra/public/apps/metrics_app.tsx b/x-pack/plugins/infra/public/apps/metrics_app.tsx index d91c64de933e6..37ef29a2b0cd1 100644 --- a/x-pack/plugins/infra/public/apps/metrics_app.tsx +++ b/x-pack/plugins/infra/public/apps/metrics_app.tsx @@ -25,14 +25,20 @@ import { prepareMountElement } from './common_styles'; export const renderApp = ( core: CoreStart, plugins: InfraClientStartDeps, - { element, history }: AppMountParameters + { element, history, setHeaderActionMenu }: AppMountParameters ) => { const apolloClient = createApolloClient(core.http.fetch); prepareMountElement(element); ReactDOM.render( - , + , element ); @@ -46,7 +52,8 @@ const MetricsApp: React.FC<{ core: CoreStart; history: History; plugins: InfraClientStartDeps; -}> = ({ apolloClient, core, history, plugins }) => { + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; +}> = ({ apolloClient, core, history, plugins, setHeaderActionMenu }) => { const uiCapabilities = core.application.capabilities; return ( @@ -54,6 +61,7 @@ const MetricsApp: React.FC<{ diff --git a/x-pack/plugins/infra/public/components/navigation/app_navigation.tsx b/x-pack/plugins/infra/public/components/navigation/app_navigation.tsx index eae39c9d1b253..9da892ec92ec1 100644 --- a/x-pack/plugins/infra/public/components/navigation/app_navigation.tsx +++ b/x-pack/plugins/infra/public/components/navigation/app_navigation.tsx @@ -24,8 +24,7 @@ export const AppNavigation = ({ 'aria-label': label, children }: AppNavigationPr const Nav = euiStyled.nav` background: ${(props) => props.theme.eui.euiColorEmptyShade}; border-bottom: ${(props) => props.theme.eui.euiBorderThin}; - padding: ${(props) => - `${props.theme.eui.euiSize} ${props.theme.eui.euiSizeL} ${props.theme.eui.euiSize} ${props.theme.eui.euiSizeL}`}; + padding: ${(props) => `${props.theme.eui.euiSizeS} ${props.theme.eui.euiSizeL}`}; .euiTabs { padding-left: 3px; margin-left: -3px; diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index 10189c0d8076c..d091f55956923 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -6,7 +6,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useContext } from 'react'; import { Route, Switch } from 'react-router-dom'; import useMount from 'react-use/lib/useMount'; @@ -24,9 +24,12 @@ import { LogEntryCategoriesPage } from './log_entry_categories'; import { LogEntryRatePage } from './log_entry_rate'; import { LogsSettingsPage } from './settings'; import { StreamPage } from './stream'; +import { HeaderMenuPortal } from '../../../../observability/public'; +import { HeaderActionMenuContext } from '../../utils/header_action_menu_provider'; export const LogsPageContent: React.FunctionComponent = () => { const uiCapabilities = useKibana().services.application?.capabilities; + const { setHeaderActionMenu } = useContext(HeaderActionMenuContext); const { initialize } = useLogSourceContext(); @@ -66,6 +69,28 @@ export const LogsPageContent: React.FunctionComponent = () => { + {setHeaderActionMenu && ( + + + + + + + + {ADD_DATA_LABEL} + + + + + )} +

{ - - - - - - {ADD_DATA_LABEL} - - diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 022c62b6bb06b..222278dde3314 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -40,6 +40,8 @@ import { SourceConfigurationFields } from '../../graphql/types'; import { AlertPrefillProvider } from '../../alerting/use_alert_prefill'; import { InfraMLCapabilitiesProvider } from '../../containers/ml/infra_ml_capabilities'; import { AnomalyDetectionFlyout } from './inventory_view/components/ml/anomaly_detection/anomoly_detection_flyout'; +import { HeaderMenuPortal } from '../../../../observability/public'; +import { HeaderActionMenuContext } from '../../utils/header_action_menu_provider'; const ADD_DATA_LABEL = i18n.translate('xpack.infra.metricsHeaderAddDataButtonLabel', { defaultMessage: 'Add data', @@ -47,6 +49,7 @@ const ADD_DATA_LABEL = i18n.translate('xpack.infra.metricsHeaderAddDataButtonLab export const InfrastructurePage = ({ match }: RouteComponentProps) => { const uiCapabilities = useKibana().services.application?.capabilities; + const { setHeaderActionMenu } = useContext(HeaderActionMenuContext); const kibana = useKibana(); @@ -72,6 +75,32 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { })} /> + {setHeaderActionMenu && ( + + + + + + + + + + + + {ADD_DATA_LABEL} + + + + + )} +
{ ]} /> - - - - - - - - - - - {ADD_DATA_LABEL} - - - diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx index 6ee3c9f1fae80..83ba3726dacb9 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx @@ -4,10 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { debounce } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { EuiSearchBar, EuiSpacer, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { + EuiSearchBar, + EuiSpacer, + EuiEmptyPrompt, + EuiButton, + EuiText, + EuiIconTip, + Query, +} from '@elastic/eui'; import { useProcessList, SortBy, @@ -20,6 +28,7 @@ import { ProcessesTable } from './processes_table'; import { parseSearchString } from './parse_search_string'; const TabComponent = ({ currentTime, node, nodeType, options }: TabProps) => { + const [searchBarState, setSearchBarState] = useState(Query.MATCH_ALL); const [searchFilter, setSearchFilter] = useState(''); const [sortBy, setSortBy] = useState({ name: 'cpu', @@ -45,14 +54,23 @@ const TabComponent = ({ currentTime, node, nodeType, options }: TabProps) => { ); const debouncedSearchOnChange = useMemo( - () => - debounce<(props: { queryText: string }) => void>( - ({ queryText }) => setSearchFilter(queryText), - 500 - ), + () => debounce<(queryText: string) => void>((queryText) => setSearchFilter(queryText), 500), [setSearchFilter] ); + const searchBarOnChange = useCallback( + ({ query, queryText }) => { + setSearchBarState(query); + debouncedSearchOnChange(queryText); + }, + [setSearchBarState, debouncedSearchOnChange] + ); + + const clearSearchBar = useCallback(() => { + setSearchBarState(Query.MATCH_ALL); + setSearchFilter(''); + }, [setSearchBarState, setSearchFilter]); + return ( @@ -61,8 +79,34 @@ const TabComponent = ({ currentTime, node, nodeType, options }: TabProps) => { processSummary={(!error ? response?.summary : null) ?? { total: 0 }} /> + +

+ {i18n.translate('xpack.infra.metrics.nodeDetails.processesHeader', { + defaultMessage: 'Top processes', + })}{' '} + +

+
+ { processList={response?.processList ?? []} sortBy={sortBy} setSortBy={setSortBy} + clearSearchBar={clearSearchBar} /> ) : ( {i18n.translate('xpack.infra.metrics.nodeDetails.processListError', { - defaultMessage: 'Unable to show process data', + defaultMessage: 'Unable to load process data', })} } diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx index 1952ba947761c..3e4b066afa157 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx @@ -7,6 +7,7 @@ import React, { useMemo, useState, useCallback } from 'react'; import { omit } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTable, EuiTableHeader, @@ -15,6 +16,9 @@ import { EuiTableRowCell, EuiLoadingChart, EuiEmptyPrompt, + EuiText, + EuiLink, + EuiButton, SortableProperties, LEFT_ALIGNMENT, RIGHT_ALIGNMENT, @@ -34,6 +38,7 @@ interface TableProps { isLoading: boolean; sortBy: SortBy; setSortBy: (s: SortBy) => void; + clearSearchBar: () => void; } function useSortableProperties( @@ -66,6 +71,7 @@ export const ProcessesTable = ({ isLoading, sortBy, setSortBy, + clearSearchBar, }: TableProps) => { const { updateSortableProperties } = useSortableProperties( [ @@ -102,13 +108,42 @@ export const ProcessesTable = ({ if (currentItems.length === 0) return ( + {i18n.translate('xpack.infra.metrics.nodeDetails.noProcesses', { - defaultMessage: 'No processes matched these search terms', + defaultMessage: 'No processes found', })} - + + } + body={ + + + + + ), + }} + /> + + } + actions={ + + {i18n.translate('xpack.infra.metrics.nodeDetails.noProcessesClearFilters', { + defaultMessage: 'Clear filters', + })} + } /> ); diff --git a/x-pack/plugins/infra/public/utils/header_action_menu_provider.tsx b/x-pack/plugins/infra/public/utils/header_action_menu_provider.tsx new file mode 100644 index 0000000000000..141b3bcc9a162 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/header_action_menu_provider.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { AppMountParameters } from 'kibana/public'; + +interface ContextProps { + setHeaderActionMenu?: AppMountParameters['setHeaderActionMenu']; +} + +export const HeaderActionMenuContext = React.createContext({}); + +export const HeaderActionMenuProvider: React.FC> = ({ + setHeaderActionMenu, + children, +}) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss index a1a072be77f81..0f512e535c9d1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss @@ -114,8 +114,12 @@ right: 0; } -.lnsLayerPanel__paletteColor { - height: $euiSizeXS; +.lnsLayerPanel__palette { + border-radius: 0 0 ($euiBorderRadius - 1px) ($euiBorderRadius - 1px); + + &::after { + border: none; + } } .lnsLayerPanel__dimensionLink { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/palette_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/palette_indicator.tsx index 7e65fe7025932..b27451236e3b4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/palette_indicator.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/palette_indicator.tsx @@ -5,23 +5,18 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiColorPaletteDisplay } from '@elastic/eui'; import { AccessorConfig } from '../../../types'; export function PaletteIndicator({ accessorConfig }: { accessorConfig: AccessorConfig }) { if (accessorConfig.triggerIcon !== 'colorBy' || !accessorConfig.palette) return null; return ( - - {accessorConfig.palette.map((color) => ( - - ))} - +
+ +
); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx index 2fb2bef7f9787..8bceac180f0eb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx @@ -5,7 +5,7 @@ */ import React, { MouseEventHandler } from 'react'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { EuiPopover, EuiLink } from '@elastic/eui'; import { createMockedIndexPattern } from '../../../mocks'; @@ -28,8 +28,7 @@ const defaultProps = { Button: ({ onClick }: { onClick: MouseEventHandler }) => ( trigger ), - isOpenByCreation: true, - setIsOpenByCreation: jest.fn(), + initiallyOpen: true, }; describe('filter popover', () => { @@ -39,16 +38,14 @@ describe('filter popover', () => { }, })); it('should be open if is open by creation', () => { - const setIsOpenByCreation = jest.fn(); - const instance = shallow( - - ); + const instance = mount(); + instance.update(); expect(instance.find(EuiPopover).prop('isOpen')).toEqual(true); act(() => { instance.find(EuiPopover).prop('closePopover')!(); }); instance.update(); - expect(setIsOpenByCreation).toHaveBeenCalledWith(false); + expect(instance.find(EuiPopover).prop('isOpen')).toEqual(false); }); it('should call setFilter when modifying QueryInput', () => { const setFilter = jest.fn(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index ca84c072be5ce..df01b8e4b4afc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -5,7 +5,7 @@ */ import './filter_popover.scss'; -import React, { MouseEventHandler, useState } from 'react'; +import React, { MouseEventHandler, useEffect, useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import { EuiPopover, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -19,23 +19,24 @@ export const FilterPopover = ({ setFilter, indexPattern, Button, - isOpenByCreation, - setIsOpenByCreation, + initiallyOpen, }: { filter: FilterValue; setFilter: Function; indexPattern: IndexPattern; Button: React.FunctionComponent<{ onClick: MouseEventHandler }>; - isOpenByCreation: boolean; - setIsOpenByCreation: Function; + initiallyOpen: boolean; }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const inputRef = React.useRef(); + // set popover open on start to work around EUI bug + useEffect(() => { + setIsPopoverOpen(initiallyOpen); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const closePopover = () => { - if (isOpenByCreation) { - setIsOpenByCreation(false); - } if (isPopoverOpen) { setIsPopoverOpen(false); } @@ -59,15 +60,12 @@ export const FilterPopover = ({ data-test-subj="indexPattern-filters-existingFilterContainer" anchorClassName="eui-fullWidth" panelClassName="lnsIndexPatternDimensionEditor__filtersEditor" - isOpen={isOpenByCreation || isPopoverOpen} + isOpen={isPopoverOpen} ownFocus closePopover={() => closePopover()} button={ -
-
- - + + + + + + +
+ +
+
+ + + +
+
+ +
+
+ -
-
+ -
- - - - - - + + ); } ); + +const SchemaInformation = ({ + closePopover, + setActivePopover, + isOpen, +}: { + closePopover: () => void; + setActivePopover: (value: 'schemaInfo' | null) => void; + isOpen: boolean; +}) => { + const colorMap = useColors(); + const sourceAndSchema = useSelector(selectors.resolverTreeSourceAndSchema); + const setAsActivePopover = useCallback(() => setActivePopover('schemaInfo'), [setActivePopover]); + + const schemaInfoButtonTitle = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.schemaInfoButtonTitle', + { + defaultMessage: 'Schema Information', + } + ); + + const unknownSchemaValue = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.unknownSchemaValue', + { + defaultMessage: 'Unknown', + } + ); + + return ( + + } + isOpen={isOpen} + closePopover={closePopover} + anchorPosition="leftCenter" + > + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaInfoTitle', { + defaultMessage: 'process tree', + })} + + +
+ + <> + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaSource', { + defaultMessage: 'source', + })} + + + {sourceAndSchema?.dataSource ?? unknownSchemaValue} + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaID', { + defaultMessage: 'id', + })} + + + {sourceAndSchema?.schema.id ?? unknownSchemaValue} + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaEdge', { + defaultMessage: 'edge', + })} + + + {sourceAndSchema?.schema.parent ?? unknownSchemaValue} + + + +
+
+ ); +}; + +// This component defines the cube legend that allows users to identify the meaning of the cubes +// Should be updated to be dynamic if and when non process based resolvers are possible +const NodeLegend = ({ + closePopover, + setActivePopover, + isOpen, +}: { + closePopover: () => void; + setActivePopover: (value: 'nodeLegend') => void; + isOpen: boolean; +}) => { + const setAsActivePopover = useCallback(() => setActivePopover('nodeLegend'), [setActivePopover]); + const colorMap = useColors(); + + const nodeLegendButtonTitle = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.nodeLegendButtonTitle', + { + defaultMessage: 'Node Legend', + } + ); + + return ( + + } + isOpen={isOpen} + closePopover={closePopover} + anchorPosition="leftCenter" + > + + {i18n.translate('xpack.securitySolution.resolver.graphControls.nodeLegend', { + defaultMessage: 'legend', + })} + +
+ + <> + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.runningProcessCube', + { + defaultMessage: 'Running Process', + } + )} + + + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.terminatedProcessCube', + { + defaultMessage: 'Terminated Process', + } + )} + + + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.currentlyLoadingCube', + { + defaultMessage: 'Loading Process', + } + )} + + + + + + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.errorCube', { + defaultMessage: 'Error', + })} + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx index cc5f39e985d9e..99c57757fbb6a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx @@ -17,40 +17,44 @@ interface StyledSVGCube { } import { useCubeAssets } from '../use_cube_assets'; import { useSymbolIDs } from '../use_symbol_ids'; +import { NodeDataStatus } from '../../types'; /** * Icon representing a process node. */ export const CubeForProcess = memo(function ({ className, - running, + size = '2.15em', + state, isOrigin, 'data-test-subj': dataTestSubj, }: { 'data-test-subj'?: string; /** - * True if the process represented by the node is still running. + * The state of the process's node data (for endpoint the process's lifecycle events) */ - running: boolean; + state: NodeDataStatus; + /** The css size (px, em, etc...) for the width and height of the svg cube. Defaults to 2.15em */ + size?: string; isOrigin?: boolean; className?: string; }) { - const { cubeSymbol, strokeColor } = useCubeAssets(!running, false); + const { cubeSymbol, strokeColor } = useCubeAssets(state, false); const { processCubeActiveBacking } = useSymbolIDs(); return ( {i18n.translate('xpack.securitySolution.resolver.node_icon', { - defaultMessage: '{running, select, true {Running Process} false {Terminated Process}}', - values: { running }, + defaultMessage: `{state, select, running {Running Process} terminated {Terminated Process} loading {Loading Process} error {Error Process}}`, + values: { state }, })} {isOrigin && ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx index 4936cf0cbb80e..003182bd5f1b7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx @@ -29,6 +29,7 @@ import { useLinkProps } from '../use_link_props'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { deepObjectEntries } from './deep_object_entries'; import { useFormattedDate } from './use_formatted_date'; +import * as nodeDataModel from '../../models/node_data'; const eventDetailRequestError = i18n.translate( 'xpack.securitySolution.resolver.panel.eventDetail.requestError', @@ -39,23 +40,24 @@ const eventDetailRequestError = i18n.translate( export const EventDetail = memo(function EventDetail({ nodeID, - eventID, eventCategory: eventType, }: { nodeID: string; - eventID: string; /** The event type to show in the breadcrumbs */ eventCategory: string; }) { const isEventLoading = useSelector(selectors.isCurrentRelatedEventLoading); - const isProcessTreeLoading = useSelector(selectors.isTreeLoading); + const isTreeLoading = useSelector(selectors.isTreeLoading); + const processEvent = useSelector((state: ResolverState) => + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) + ); + const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); - const isLoading = isEventLoading || isProcessTreeLoading; + const isNodeDataLoading = nodeStatus === 'loading'; + const isLoading = isEventLoading || isTreeLoading || isNodeDataLoading; const event = useSelector(selectors.currentRelatedEventData); - const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) - ); + return isLoading ? ( @@ -90,7 +92,7 @@ const EventDetailContents = memo(function ({ * Event type to use in the breadcrumbs */ eventType: string; - processEvent: SafeResolverEvent | null; + processEvent: SafeResolverEvent | undefined; }) { const timestamp = eventModel.timestampSafeVersion(event); const formattedDate = diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx index f6fbd280e7ed5..c6e81f691e2fe 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx @@ -37,7 +37,6 @@ export const PanelRouter = memo(function () { return ( ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx index 27a7723d7d656..fedf1ae2499ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx @@ -20,6 +20,7 @@ import { GeneratedText } from '../generated_text'; import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import { processPath, processPID } from '../../models/process_event'; +import * as nodeDataModel from '../../models/node_data'; import { CubeForProcess } from './cube_for_process'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { useCubeAssets } from '../use_cube_assets'; @@ -28,28 +29,35 @@ import { PanelLoading } from './panel_loading'; import { StyledPanel } from '../styles'; import { useLinkProps } from '../use_link_props'; import { useFormattedDate } from './use_formatted_date'; +import { PanelContentError } from './panel_content_error'; const StyledCubeForProcess = styled(CubeForProcess)` position: relative; top: 0.75em; `; +const nodeDetailError = i18n.translate('xpack.securitySolution.resolver.panel.nodeDetail.Error', { + defaultMessage: 'Node details were unable to be retrieved', +}); + export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) { const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) ); - return ( - <> - {processEvent === null ? ( - - - - ) : ( - - - - )} - + const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); + + return nodeStatus === 'loading' ? ( + + + + ) : processEvent ? ( + + + + ) : ( + + + ); }); @@ -65,9 +73,7 @@ const NodeDetailView = memo(function ({ nodeID: string; }) { const processName = eventModel.processNameSafeVersion(processEvent); - const isProcessTerminated = useSelector((state: ResolverState) => - selectors.isProcessTerminated(state)(nodeID) - ); + const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); const relatedEventTotal = useSelector((state: ResolverState) => { return selectors.relatedEventTotalCount(state)(nodeID); }); @@ -171,7 +177,7 @@ const NodeDetailView = memo(function ({ }, ]; }, [processName, nodesLinkNavProps]); - const { descriptionText } = useCubeAssets(isProcessTerminated, false); + const { descriptionText } = useCubeAssets(nodeState, false); const nodeDetailNavProps = useLinkProps({ panelView: 'nodeEvents', @@ -187,7 +193,7 @@ const NodeDetailView = memo(function ({ {processName} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx index d0601fad43f57..6f0c336ab3df4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx @@ -13,21 +13,21 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { useSelector } from 'react-redux'; import { Breadcrumbs } from './breadcrumbs'; import * as event from '../../../../common/endpoint/models/event'; -import { ResolverNodeStats } from '../../../../common/endpoint/types'; +import { EventStats } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { ResolverState } from '../../types'; import { StyledPanel } from '../styles'; import { PanelLoading } from './panel_loading'; import { useLinkProps } from '../use_link_props'; +import * as nodeDataModel from '../../models/node_data'; export function NodeEvents({ nodeID }: { nodeID: string }) { const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) ); - const relatedEventsStats = useSelector((state: ResolverState) => - selectors.relatedEventsStats(state)(nodeID) - ); - if (processEvent === null || relatedEventsStats === undefined) { + const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID)); + + if (processEvent === undefined || nodeStats === undefined) { return ( @@ -39,10 +39,10 @@ export function NodeEvents({ nodeID }: { nodeID: string }) { - + ); } @@ -64,7 +64,7 @@ const EventCategoryLinks = memo(function ({ relatedStats, }: { nodeID: string; - relatedStats: ResolverNodeStats; + relatedStats: EventStats; }) { interface EventCountsTableView { eventType: string; @@ -72,7 +72,7 @@ const EventCategoryLinks = memo(function ({ } const rows = useMemo(() => { - return Object.entries(relatedStats.events.byCategory).map( + return Object.entries(relatedStats.byCategory).map( ([eventType, count]): EventCountsTableView => { return { eventType, @@ -80,7 +80,7 @@ const EventCategoryLinks = memo(function ({ }; } ); - }, [relatedStats.events.byCategory]); + }, [relatedStats.byCategory]); const columns = useMemo>>( () => [ diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx index c9648c6f562e5..fbfba38295ea4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx @@ -42,9 +42,7 @@ export const NodeEventsInCategory = memo(function ({ nodeID: string; eventCategory: string; }) { - const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) - ); + const node = useSelector((state: ResolverState) => selectors.graphNodeForID(state)(nodeID)); const eventCount = useSelector((state: ResolverState) => selectors.totalRelatedEventCountForNode(state)(nodeID) ); @@ -57,13 +55,13 @@ export const NodeEventsInCategory = memo(function ({ const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory); return ( <> - {isLoading || processEvent === null ? ( + {isLoading ? ( ) : ( - {hasError ? ( + {hasError || !node ? ( { useCallback((state: ResolverState) => { const { processNodePositions } = selectors.layout(state); const view: ProcessTableView[] = []; - for (const processEvent of processNodePositions.keys()) { - const name = eventModel.processNameSafeVersion(processEvent); - const nodeID = eventModel.entityIDSafeVersion(processEvent); + for (const treeNode of processNodePositions.keys()) { + const name = nodeModel.nodeName(treeNode); + const nodeID = nodeModel.nodeID(treeNode); if (nodeID !== undefined) { view.push({ name, - timestamp: eventModel.timestampAsDateSafeVersion(processEvent), + timestamp: nodeModel.timestampAsDate(treeNode), nodeID, }); } @@ -119,7 +119,8 @@ export const NodeList = memo(() => { const children = useSelector(selectors.hasMoreChildren); const ancestors = useSelector(selectors.hasMoreAncestors); - const showWarning = children === true || ancestors === true; + const generations = useSelector(selectors.hasMoreGenerations); + const showWarning = children === true || ancestors === true || generations === true; const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []); return ( @@ -141,9 +142,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { const isOrigin = useSelector((state: ResolverState) => { return selectors.originID(state) === nodeID; }); - const isTerminated = useSelector((state: ResolverState) => - nodeID === undefined ? false : selectors.isProcessTerminated(state)(nodeID) - ); + const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); const { descriptionText } = useColors(); const linkProps = useLinkProps({ panelView: 'nodeDetail', panelParameters: { nodeID } }); const dispatch: (action: ResolverAction) => void = useDispatch(); @@ -162,7 +161,12 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { [timestamp, linkProps, dispatch, nodeID] ); return ( - + {name === undefined ? ( {i18n.translate( @@ -175,7 +179,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { ) : ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx index 39a5130ecaf68..6f20063d10d0a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx @@ -23,6 +23,8 @@ describe('Resolver: panel loading and resolution states', () => { nodeID: 'origin', eventCategory: 'registry', eventID: firstRelatedEventID, + eventTimestamp: '0', + winlogRecordID: '0', }, panelView: 'eventDetail', }); @@ -129,7 +131,7 @@ describe('Resolver: panel loading and resolution states', () => { }); describe('when navigating to the event categories panel', () => { - let resumeRequest: (pausableRequest: ['entities']) => void; + let resumeRequest: (pausableRequest: ['eventsWithEntityIDAndCategory']) => void; beforeEach(() => { const { metadata: { databaseDocumentID }, @@ -140,7 +142,7 @@ describe('Resolver: panel loading and resolution states', () => { resumeRequest = resume; memoryHistory = createMemoryHistory(); - pause(['entities']); + pause(['eventsWithEntityIDAndCategory']); simulator = new Simulator({ dataAccessLayer, @@ -170,7 +172,7 @@ describe('Resolver: panel loading and resolution states', () => { }); it('should successfully load the events in category panel', async () => { - await resumeRequest(['entities']); + await resumeRequest(['eventsWithEntityIDAndCategory']); await expect( simulator.map(() => ({ resolverPanelLoading: simulator.testSubject('resolver:panel:loading').length, @@ -186,7 +188,7 @@ describe('Resolver: panel loading and resolution states', () => { }); describe('when navigating to the node detail panel', () => { - let resumeRequest: (pausableRequest: ['entities']) => void; + let resumeRequest: (pausableRequest: ['nodeData']) => void; beforeEach(() => { const { metadata: { databaseDocumentID }, @@ -197,7 +199,7 @@ describe('Resolver: panel loading and resolution states', () => { resumeRequest = resume; memoryHistory = createMemoryHistory(); - pause(['entities']); + pause(['nodeData']); simulator = new Simulator({ dataAccessLayer, @@ -226,7 +228,7 @@ describe('Resolver: panel loading and resolution states', () => { }); it('should successfully load the events in category panel', async () => { - await resumeRequest(['entities']); + await resumeRequest(['nodeData']); await expect( simulator.map(() => ({ resolverPanelLoading: simulator.testSubject('resolver:panel:loading').length, diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 7a3657fe93514..ab6083c796b3a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -9,12 +9,13 @@ import styled from 'styled-components'; import { htmlIdGenerator, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { NodeSubMenu } from './styles'; import { applyMatrix3 } from '../models/vector2'; import { Vector2, Matrix3, ResolverState } from '../types'; -import { SafeResolverEvent } from '../../../common/endpoint/types'; +import { ResolverNode } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; -import * as eventModel from '../../../common/endpoint/models/event'; +import * as nodeModel from '../../../common/endpoint/models/node'; import * as selectors from '../store/selectors'; import { fontSize } from './font_size'; import { useCubeAssets } from './use_cube_assets'; @@ -65,9 +66,50 @@ const StyledDescriptionText = styled.div` z-index: 45; `; -const StyledOuterGroup = styled.g` +interface StyledEuiButtonContent { + readonly isShowingIcon: boolean; +} + +const StyledEuiButtonContent = styled.span` + padding: ${(props) => (props.isShowingIcon ? '0px' : '0 12px')}; +`; + +const StyledOuterGroup = styled.g<{ isNodeLoading: boolean }>` fill: none; pointer-events: visiblePainted; + // The below will apply the loading css to the element that references the cube + // when the nodeData is loading for the current node + ${(props) => + props.isNodeLoading && + ` + & .cube { + animation-name: pulse; + /** + * his is a multiple of .6 so it can match up with the EUI button's loading spinner + * which is (0.6s). Using .6 here makes it a bit too fast. + */ + animation-duration: 1.8s; + animation-delay: 0; + animation-direction: normal; + animation-iteration-count: infinite; + animation-timing-function: linear; + } + + /** + * Animation loading state of the cube. + */ + @keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.35; + } + 100% { + opacity: 1; + } + } + `} `; /** @@ -77,9 +119,9 @@ const UnstyledProcessEventDot = React.memo( ({ className, position, - event, + node, + nodeID, projectionMatrix, - isProcessTerminated, timeAtRender, }: { /** @@ -87,21 +129,21 @@ const UnstyledProcessEventDot = React.memo( */ className?: string; /** - * The positon of the process node, in 'world' coordinates. + * The positon of the graph node, in 'world' coordinates. */ position: Vector2; /** - * An event which contains details about the process node. + * An event which contains details about the graph node. */ - event: SafeResolverEvent; + node: ResolverNode; /** - * projectionMatrix which can be used to convert `position` to screen coordinates. + * The unique identifier for the node based on a datasource id */ - projectionMatrix: Matrix3; + nodeID: string; /** - * Whether or not to show the process as terminated. + * projectionMatrix which can be used to convert `position` to screen coordinates. */ - isProcessTerminated: boolean; + projectionMatrix: Matrix3; /** * The time (unix epoch) at render. @@ -125,14 +167,7 @@ const UnstyledProcessEventDot = React.memo( const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant); const selectedNode = useSelector(selectors.selectedNode); const originID = useSelector(selectors.originID); - const nodeID: string | undefined = eventModel.entityIDSafeVersion(event); - if (nodeID === undefined) { - // NB: this component should be taking nodeID as a `string` instead of handling this logic here - throw new Error('Tried to render a node with no ID'); - } - const relatedEventStats = useSelector((state: ResolverState) => - selectors.relatedEventsStats(state)(nodeID) - ); + const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID)); // define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID. // this is used to link nodes via aria attributes @@ -218,6 +253,11 @@ const UnstyledProcessEventDot = React.memo( | null; } = React.createRef(); const colorMap = useColors(); + + const nodeState = useSelector((state: ResolverState) => + selectors.nodeDataStatus(state)(nodeID) + ); + const isNodeLoading = nodeState === 'loading'; const { backingFill, cubeSymbol, @@ -226,7 +266,7 @@ const UnstyledProcessEventDot = React.memo( labelButtonFill, strokeColor, } = useCubeAssets( - isProcessTerminated, + nodeState, /** * There is no definition for 'trigger process' yet. return false. */ false @@ -257,19 +297,29 @@ const UnstyledProcessEventDot = React.memo( if (animationTarget.current?.beginElement) { animationTarget.current.beginElement(); } - dispatch({ - type: 'userSelectedResolverNode', - payload: nodeID, - }); - processDetailNavProps.onClick(clickEvent); + + if (nodeState === 'error') { + dispatch({ + type: 'userReloadedResolverNode', + payload: nodeID, + }); + } else { + dispatch({ + type: 'userSelectedResolverNode', + payload: nodeID, + }); + processDetailNavProps.onClick(clickEvent); + } }, - [animationTarget, dispatch, nodeID, processDetailNavProps] + [animationTarget, dispatch, nodeID, processDetailNavProps, nodeState] ); const grandTotal: number | null = useSelector((state: ResolverState) => - selectors.relatedEventTotalForProcess(state)(event) + selectors.statsTotalForNode(state)(node) ); + const nodeName = nodeModel.nodeName(node); + /* eslint-disable jsx-a11y/click-events-have-key-events */ /** * Key event handling (e.g. 'Enter'/'Space') is provisioned by the `EuiKeyboardAccessible` component @@ -315,7 +365,7 @@ const UnstyledProcessEventDot = React.memo( zIndex: 30, }} > - + - + - {eventModel.processNameSafeVersion(event)} + {i18n.translate('xpack.securitySolution.resolver.node_button_name', { + defaultMessage: `{nodeState, select, error {Reload {nodeName}} other {{nodeName}}}`, + values: { + nodeState, + nodeName, + }, + })} - + 0 && ( )} diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx index d8d8de640d786..fa1686e7ea4b6 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx @@ -35,12 +35,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 1, resolverGraphError: 0, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -66,12 +66,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 1, resolverGraphError: 0, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -96,12 +96,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 1, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -126,13 +126,13 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, resolverGraphNodes: simulator.testSubject('resolver:node').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 0, - resolverGraph: 1, + resolverTree: 1, resolverGraphNodes: 0, }); }); @@ -158,13 +158,13 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, resolverGraphNodes: simulator.testSubject('resolver:node').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 0, - resolverGraph: 1, + resolverTree: 1, resolverGraphNodes: 3, }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index ed969b913a72e..65b72cf4bfa77 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -19,7 +19,7 @@ import { useCamera } from './use_camera'; import { SymbolDefinitions } from './symbol_definitions'; import { useStateSyncingActions } from './use_state_syncing_actions'; import { StyledMapContainer, GraphContainer } from './styles'; -import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; +import * as nodeModel from '../../../common/endpoint/models/node'; import { SideEffectContext } from './side_effect_context'; import { ResolverProps, ResolverState } from '../types'; import { PanelRouter } from './panels'; @@ -54,7 +54,7 @@ export const ResolverWithoutProviders = React.memo( } = useSelector((state: ResolverState) => selectors.visibleNodesAndEdgeLines(state)(timeAtRender) ); - const terminatedProcesses = useSelector(selectors.terminatedProcesses); + const { projectionMatrix, ref: cameraRef, onMouseDown } = useCamera(); const ref = useCallback( @@ -113,15 +113,18 @@ export const ResolverWithoutProviders = React.memo( /> ) )} - {[...processNodePositions].map(([processEvent, position]) => { - const processEntityId = entityIDSafeVersion(processEvent); + {[...processNodePositions].map(([treeNode, position]) => { + const nodeID = nodeModel.nodeID(treeNode); + if (nodeID === undefined) { + throw new Error('Tried to render a node without an ID'); + } return ( ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 6312991ddb743..e24c4b5664e42 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; import { EuiI18nNumber } from '@elastic/eui'; -import { ResolverNodeStats } from '../../../common/endpoint/types'; +import { EventStats } from '../../../common/endpoint/types'; import { useRelatedEventByCategoryNavigation } from './use_related_event_by_category_navigation'; import { useColors } from './use_colors'; @@ -67,7 +67,7 @@ export const NodeSubMenuComponents = React.memo( ({ className, nodeID, - relatedEventStats, + nodeStats, }: { className?: string; // eslint-disable-next-line react/no-unused-prop-types @@ -76,18 +76,18 @@ export const NodeSubMenuComponents = React.memo( * Receive the projection matrix, so we can see when the camera position changed, so we can force the submenu to reposition itself. */ nodeID: string; - relatedEventStats: ResolverNodeStats | undefined; + nodeStats: EventStats | undefined; }) => { // The last projection matrix that was used to position the popover const relatedEventCallbacks = useRelatedEventByCategoryNavigation({ nodeID, - categories: relatedEventStats?.events?.byCategory, + categories: nodeStats?.byCategory, }); const relatedEventOptions = useMemo(() => { - if (relatedEventStats === undefined) { + if (nodeStats === undefined) { return []; } else { - return Object.entries(relatedEventStats.events.byCategory).map(([category, total]) => { + return Object.entries(nodeStats.byCategory).map(([category, total]) => { const [mantissa, scale, hasRemainder] = compactNotationParts(total || 0); const prefix = ( { diff --git a/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx b/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx index edf551c6cbeb9..b06cce11661e8 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx @@ -8,10 +8,59 @@ import React, { memo } from 'react'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public'; import { useSymbolIDs } from './use_symbol_ids'; import { usePaintServerIDs } from './use_paint_server_ids'; +const loadingProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.loadingProcess', + { + defaultMessage: 'Loading Process', + } +); + +const errorProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.errorProcess', + { + defaultMessage: 'Error Process', + } +); + +const runningProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.runningProcess', + { + defaultMessage: 'Running Process', + } +); + +const triggerProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.triggerProcess', + { + defaultMessage: 'Trigger Process', + } +); + +const terminatedProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.terminatedProcess', + { + defaultMessage: 'Terminated Process', + } +); + +const terminatedTriggerProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.terminatedTriggerProcess', + { + defaultMessage: 'Terminated Trigger Process', + } +); + +const hoveredProcessBackgroundTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.hoveredProcessBackground', + { + defaultMessage: 'Hovered Process Background', + } +); /** * PaintServers: Where color palettes, gradients, patterns and other similar concerns * are exposed to the component @@ -20,6 +69,17 @@ const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => { const paintServerIDs = usePaintServerIDs(); return ( <> + + + + { paintOrder="normal" /> + + {loadingProcessTitle} + + + + {errorProcessTitle} + + + + + + + - {'Running Process'} + {runningProcessTitle} { /> - {'resolver_dark process running'} + {triggerProcessTitle} { /> - {'Terminated Process'} + {terminatedProcessTitle} { - {'Terminated Trigger Process'} + {terminatedTriggerProcessTitle} {isDarkMode && ( { - {'resolver active backing'} + {hoveredProcessBackgroundTitle} { /** Enzyme full DOM wrapper for the element the camera is attached to. */ @@ -247,43 +248,48 @@ describe('useCamera on an unpainted element', () => { expect(simulator.mock.requestAnimationFrame).not.toHaveBeenCalled(); }); describe('when the camera begins animation', () => { - let process: SafeResolverEvent; + let node: ResolverNode; beforeEach(async () => { - const events: SafeResolverEvent[] = []; - const numberOfEvents: number = 10; + const nodes: ResolverNode[] = []; + const numberOfNodes: number = 10; - for (let index = 0; index < numberOfEvents; index++) { - const uniquePpid = index === 0 ? undefined : index - 1; - events.push( - mockProcessEvent({ - endgame: { - unique_pid: index, - unique_ppid: uniquePpid, - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - }, + for (let index = 0; index < numberOfNodes; index++) { + const parentID = index === 0 ? undefined : String(index - 1); + nodes.push( + mockResolverNode({ + id: String(index), + name: '', + parentID, + timestamp: 0, + stats: { total: 0, byCategory: {} }, }) ); } - const tree = mockResolverTree({ events }); + const tree = mockResolverTree({ nodes }); if (tree !== null) { + const { schema, dataSource } = endpointSourceSchema(); const serverResponseAction: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: tree, parameters: mockTreeFetcherParameters() }, + payload: { + result: tree, + dataSource, + schema, + parameters: mockTreeFetcherParameters(), + }, }; store.dispatch(serverResponseAction); } else { throw new Error('failed to create tree'); } - const processes: SafeResolverEvent[] = [ + const resolverNodes: ResolverNode[] = [ ...selectors.layout(store.getState()).processNodePositions.keys(), ]; - process = processes[processes.length - 1]; + node = resolverNodes[resolverNodes.length - 1]; if (!process) { throw new Error('missing the process to bring into view'); } simulator.controls.time = 0; - const nodeID = entityIDSafeVersion(process); + const nodeID = nodeModel.nodeID(node); if (!nodeID) { throw new Error('could not find nodeID for process'); } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts index 7daf181a7b2bb..90ce5dc22d177 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts @@ -15,6 +15,7 @@ type ResolverColorNames = | 'full' | 'graphControls' | 'graphControlsBackground' + | 'graphControlsBorderColor' | 'linkColor' | 'resolverBackground' | 'resolverEdge' @@ -38,6 +39,7 @@ export function useColors(): ColorMap { full: theme.euiColorFullShade, graphControls: theme.euiColorDarkestShade, graphControlsBackground: theme.euiColorEmptyShade, + graphControlsBorderColor: theme.euiColorLightShade, processBackingFill: `${theme.euiColorPrimary}${isDarkMode ? '1F' : '0F'}`, // Add opacity 0F = 6% , 1F = 12% resolverBackground: theme.euiColorEmptyShade, resolverEdge: isDarkMode ? theme.euiColorLightShade : theme.euiColorLightestShade, diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts b/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts index c743ebc43f2be..94f08c5f3fee3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts @@ -10,7 +10,7 @@ import { ButtonColor } from '@elastic/eui'; import euiThemeAmsterdamDark from '@elastic/eui/dist/eui_theme_amsterdam_dark.json'; import euiThemeAmsterdamLight from '@elastic/eui/dist/eui_theme_amsterdam_light.json'; import { useMemo } from 'react'; -import { ResolverProcessType } from '../types'; +import { ResolverProcessType, NodeDataStatus } from '../types'; import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public'; import { useSymbolIDs } from './use_symbol_ids'; import { useColors } from './use_colors'; @@ -19,7 +19,7 @@ import { useColors } from './use_colors'; * Provides colors and HTML IDs used to render the 'cube' graphic that accompanies nodes. */ export function useCubeAssets( - isProcessTerminated: boolean, + cubeType: NodeDataStatus, isProcessTrigger: boolean ): NodeStyleConfig { const SymbolIds = useSymbolIDs(); @@ -40,6 +40,28 @@ export function useCubeAssets( labelButtonFill: 'primary', strokeColor: theme.euiColorPrimary, }, + loadingCube: { + backingFill: colorMap.processBackingFill, + cubeSymbol: `#${SymbolIds.loadingCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.loadingProcess', { + defaultMessage: 'Loading Process', + }), + isLabelFilled: false, + labelButtonFill: 'primary', + strokeColor: theme.euiColorPrimary, + }, + errorCube: { + backingFill: colorMap.processBackingFill, + cubeSymbol: `#${SymbolIds.errorCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.errorProcess', { + defaultMessage: 'Error Process', + }), + isLabelFilled: false, + labelButtonFill: 'primary', + strokeColor: theme.euiColorPrimary, + }, runningTriggerCube: { backingFill: colorMap.triggerBackingFill, cubeSymbol: `#${SymbolIds.runningTriggerCube}`, @@ -83,16 +105,22 @@ export function useCubeAssets( [SymbolIds, colorMap, theme] ); - if (isProcessTerminated) { + if (cubeType === 'terminated') { if (isProcessTrigger) { return nodeAssets.terminatedTriggerCube; } else { return nodeAssets[processTypeToCube.processTerminated]; } - } else if (isProcessTrigger) { - return nodeAssets[processTypeToCube.processCausedAlert]; + } else if (cubeType === 'running') { + if (isProcessTrigger) { + return nodeAssets[processTypeToCube.processCausedAlert]; + } else { + return nodeAssets[processTypeToCube.processRan]; + } + } else if (cubeType === 'error') { + return nodeAssets[processTypeToCube.processError]; } else { - return nodeAssets[processTypeToCube.processRan]; + return nodeAssets[processTypeToCube.processLoading]; } } @@ -102,6 +130,8 @@ const processTypeToCube: Record = { processTerminated: 'terminatedProcessCube', unknownProcessEvent: 'runningProcessCube', processCausedAlert: 'runningTriggerCube', + processLoading: 'loadingCube', + processError: 'errorCube', unknownEvent: 'runningProcessCube', }; interface NodeStyleMap { @@ -109,6 +139,8 @@ interface NodeStyleMap { runningTriggerCube: NodeStyleConfig; terminatedProcessCube: NodeStyleConfig; terminatedTriggerCube: NodeStyleConfig; + loadingCube: NodeStyleConfig; + errorCube: NodeStyleConfig; } interface NodeStyleConfig { backingFill: string; diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts b/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts index 0336a29bb0721..10fbd58a9deb3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts @@ -23,6 +23,8 @@ export function usePaintServerIDs() { runningTriggerCube: `${prefix}-psRunningTriggerCube`, terminatedProcessCube: `${prefix}-psTerminatedProcessCube`, terminatedTriggerCube: `${prefix}-psTerminatedTriggerCube`, + loadingCube: `${prefix}-psLoadingCube`, + errorCube: `${prefix}-psErrorCube`, }; }, [resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts b/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts index 0e1fd5737a3ce..da00d4c0dbf43 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts @@ -25,6 +25,8 @@ export function useSymbolIDs() { terminatedProcessCube: `${prefix}-terminatedCube`, terminatedTriggerCube: `${prefix}-terminatedTriggerCube`, processCubeActiveBacking: `${prefix}-activeBacking`, + loadingCube: `${prefix}-loadingCube`, + errorCube: `${prefix}-errorCube`, }; }, [resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 5b558df8388e4..b53c11868998f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -72,7 +72,7 @@ const NavigationComponent: React.FC = ({ timelineFullScreen, toggleFullScreen, }) => ( - + {i18n.CLOSE_ANALYZER} 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 1d4cea700d003..0dae9a97b6e5b 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 @@ -117,7 +117,9 @@ export const getEventType = (event: Ecs): Omit => { }; export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => - get(['agent', 'type', 0], ecsData) === 'endpoint' && + (get(['agent', 'type', 0], ecsData) === 'endpoint' || + (get(['agent', 'type', 0], ecsData) === 'winlogbeat' && + get(['event', 'module', 0], ecsData) === 'sysmon')) && get(['process', 'entity_id'], ecsData)?.length === 1 && get(['process', 'entity_id', 0], ecsData) !== ''; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts index c731692e6fb89..6d4168d744fca 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -19,7 +19,7 @@ interface SupportedSchema { /** * A constraint to search for in the documented returned by Elasticsearch */ - constraint: { field: string; value: string }; + constraints: Array<{ field: string; value: string }>; /** * Schema to return to the frontend so that it can be passed in to call to the /tree API @@ -34,10 +34,12 @@ interface SupportedSchema { const supportedSchemas: SupportedSchema[] = [ { name: 'endpoint', - constraint: { - field: 'agent.type', - value: 'endpoint', - }, + constraints: [ + { + field: 'agent.type', + value: 'endpoint', + }, + ], schema: { id: 'process.entity_id', parent: 'process.parent.entity_id', @@ -47,10 +49,16 @@ const supportedSchemas: SupportedSchema[] = [ }, { name: 'winlogbeat', - constraint: { - field: 'agent.type', - value: 'winlogbeat', - }, + constraints: [ + { + field: 'agent.type', + value: 'winlogbeat', + }, + { + field: 'event.module', + value: 'sysmon', + }, + ], schema: { id: 'process.entity_id', parent: 'process.parent.entity_id', @@ -104,14 +112,17 @@ export function handleEntities(): RequestHandler { - const kqlQuery: JsonObject[] = []; - if (kql) { - kqlQuery.push(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kql))); - } + async search( + client: IScopedClusterClient, + filter: string | undefined + ): Promise { + const parsedFilters = EventsQuery.buildFilters(filter); const response: ApiResponse< SearchResponse - > = await client.asCurrentUser.search(this.buildSearch(kqlQuery)); + > = await client.asCurrentUser.search(this.buildSearch(parsedFilters)); return response.body.hits.hits.map((hit) => hit._source); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts index 3baf3a8667529..63cd3b5d694af 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts @@ -8,12 +8,12 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types'; import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { NodeID, Timerange, docValueFields } from '../utils/index'; +import { NodeID, TimeRange, docValueFields } from '../utils/index'; interface DescendantsParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -22,13 +22,13 @@ interface DescendantsParams { export class DescendantsQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; + private readonly timeRange: TimeRange; private readonly docValueFields: JsonValue[]; - constructor({ schema, indexPatterns, timerange }: DescendantsParams) { + constructor({ schema, indexPatterns, timeRange }: DescendantsParams) { this.docValueFields = docValueFields(schema); this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[], size: number): JsonObject { @@ -46,8 +46,8 @@ export class DescendantsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, @@ -126,8 +126,8 @@ export class DescendantsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts index 5253806be66ba..150b07c63ce2f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts @@ -8,12 +8,12 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types'; import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { NodeID, Timerange, docValueFields } from '../utils/index'; +import { NodeID, TimeRange, docValueFields } from '../utils/index'; interface LifecycleParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -22,13 +22,13 @@ interface LifecycleParams { export class LifecycleQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; + private readonly timeRange: TimeRange; private readonly docValueFields: JsonValue[]; - constructor({ schema, indexPatterns, timerange }: LifecycleParams) { + constructor({ schema, indexPatterns, timeRange }: LifecycleParams) { this.docValueFields = docValueFields(schema); this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[]): JsonObject { @@ -46,8 +46,8 @@ export class LifecycleQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts index 117cc3647dd0e..22d2c600feb01 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts @@ -8,7 +8,7 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { JsonObject } from '../../../../../../../../../src/plugins/kibana_utils/common'; import { EventStats, ResolverSchema } from '../../../../../../common/endpoint/types'; -import { NodeID, Timerange } from '../utils/index'; +import { NodeID, TimeRange } from '../utils/index'; interface AggBucket { key: string; @@ -28,7 +28,7 @@ interface CategoriesAgg extends AggBucket { interface StatsParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -37,11 +37,11 @@ interface StatsParams { export class StatsQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; - constructor({ schema, indexPatterns, timerange }: StatsParams) { + private readonly timeRange: TimeRange; + constructor({ schema, indexPatterns, timeRange }: StatsParams) { this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[]): JsonObject { @@ -53,8 +53,8 @@ export class StatsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts index d5e0af9dea239..796ed60ddbbc3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts @@ -80,7 +80,7 @@ describe('fetcher test', () => { descendantLevels: 1, descendants: 5, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -100,7 +100,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -163,7 +163,7 @@ describe('fetcher test', () => { descendantLevels: 2, descendants: 5, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -188,7 +188,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 5, - timerange: { + timeRange: { from: '', to: '', }, @@ -211,7 +211,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -249,7 +249,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 2, - timerange: { + timeRange: { from: '', to: '', }, @@ -292,7 +292,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 2, - timerange: { + timeRange: { from: '', to: '', }, @@ -342,7 +342,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 3, - timerange: { + timeRange: { from: '', to: '', }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts index 356357082d6ee..2ff231892a593 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts @@ -27,7 +27,7 @@ export interface TreeOptions { descendantLevels: number; descendants: number; ancestors: number; - timerange: { + timeRange: { from: string; to: string; }; @@ -76,7 +76,7 @@ export class Fetcher { const query = new StatsQuery({ indexPatterns: options.indexPatterns, schema: options.schema, - timerange: options.timerange, + timeRange: options.timeRange, }); const eventStats = await query.search(this.client, statsIDs); @@ -136,7 +136,7 @@ export class Fetcher { const query = new LifecycleQuery({ schema: options.schema, indexPatterns: options.indexPatterns, - timerange: options.timerange, + timeRange: options.timeRange, }); let nodes = options.nodes; @@ -182,7 +182,7 @@ export class Fetcher { const query = new DescendantsQuery({ schema: options.schema, indexPatterns: options.indexPatterns, - timerange: options.timerange, + timeRange: options.timeRange, }); let nodes: NodeID[] = options.nodes; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts index be08b4390a69c..c00e90a386fb6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts @@ -9,7 +9,7 @@ import { ResolverSchema } from '../../../../../../common/endpoint/types'; /** * Represents a time range filter */ -export interface Timerange { +export interface TimeRange { from: string; to: string; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts index 5bc911fb075b5..00aab683bf010 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts @@ -49,18 +49,18 @@ export class AncestryQueryHandler implements QueryHandler private toMapOfNodes(results: SafeResolverEvent[]) { return results.reduce( (nodes: Map, event: SafeResolverEvent) => { - const nodeId = entityIDSafeVersion(event); - if (!nodeId) { + const nodeID = entityIDSafeVersion(event); + if (!nodeID) { return nodes; } - let node = nodes.get(nodeId); + let node = nodes.get(nodeID); if (!node) { - node = createLifecycle(nodeId, []); + node = createLifecycle(nodeID, []); } node.lifecycle.push(event); - return nodes.set(nodeId, node); + return nodes.set(nodeID, node); }, new Map() ); 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 1121e27e6e7bc..7476d1b59bf54 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 @@ -140,9 +140,7 @@ export const createEntryNested = (field: string, entries: NestedEntriesArray): E return { field, entries, type: 'nested' }; }; -export const conditionEntriesToEntries = ( - conditionEntries: Array> -): EntriesArray => { +export const conditionEntriesToEntries = (conditionEntries: ConditionEntry[]): EntriesArray => { return conditionEntries.map((conditionEntry) => { if (conditionEntry.field === ConditionEntryField.HASH) { return createEntryMatch( diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_logistics.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_logistics.tsx index 7d3ba92cf2ad7..c3d29bce57d54 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_logistics.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_logistics.tsx @@ -23,7 +23,7 @@ import { } from '@elastic/eui'; import { Repository } from '../../../../../common/types'; -import { CronEditor, SectionError } from '../../../../shared_imports'; +import { Frequency, CronEditor, SectionError } from '../../../../shared_imports'; import { useServices } from '../../../app_context'; import { DEFAULT_POLICY_SCHEDULE, DEFAULT_POLICY_FREQUENCY } from '../../../constants'; import { useLoadRepositories } from '../../../services/http'; @@ -71,7 +71,7 @@ export const PolicyStepLogistics: React.FunctionComponent = ({ // State for cron editor const [simpleCron, setSimpleCron] = useState<{ expression: string; - frequency: string; + frequency: Frequency; }>({ expression: DEFAULT_POLICY_SCHEDULE, frequency: DEFAULT_POLICY_FREQUENCY, @@ -480,6 +480,7 @@ export const PolicyStepLogistics: React.FunctionComponent = ({ ) : ( = ({ cronExpression: expression, frequency, fieldToPreferredValueMap: newFieldToPreferredValueMap, - }: { - cronExpression: string; - frequency: string; - fieldToPreferredValueMap: any; }) => { setSimpleCron({ expression, diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_retention.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_retention.tsx index 407b9be14e3c1..ee638edd09bb8 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_retention.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_retention.tsx @@ -14,6 +14,7 @@ import { EuiButtonEmpty, EuiFieldNumber, EuiSelect, + EuiCode, } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../../common/types'; @@ -135,7 +136,10 @@ export const PolicyStepRetention: React.FunctionComponent = ({ description={ 200, + }} /> } fullWidth diff --git a/x-pack/plugins/snapshot_restore/public/application/components/retention_update_modal_provider.tsx b/x-pack/plugins/snapshot_restore/public/application/components/retention_update_modal_provider.tsx index 2765006f9dcbc..40f37d6e67e90 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/retention_update_modal_provider.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/retention_update_modal_provider.tsx @@ -25,7 +25,7 @@ import { import { useServices, useToastNotifications } from '../app_context'; import { documentationLinksService } from '../services/documentation'; -import { CronEditor } from '../../shared_imports'; +import { Frequency, CronEditor } from '../../shared_imports'; import { DEFAULT_RETENTION_SCHEDULE, DEFAULT_RETENTION_FREQUENCY } from '../constants'; import { updateRetentionSchedule } from '../services/http'; @@ -57,7 +57,7 @@ export const RetentionSettingsUpdateModalProvider: React.FunctionComponent({ expression: DEFAULT_RETENTION_SCHEDULE, frequency: DEFAULT_RETENTION_FREQUENCY, @@ -234,10 +234,6 @@ export const RetentionSettingsUpdateModalProvider: React.FunctionComponent { setSimpleCron({ expression, diff --git a/x-pack/plugins/snapshot_restore/public/application/constants/index.ts b/x-pack/plugins/snapshot_restore/public/application/constants/index.ts index 2f4945b625b53..1cf41da736e19 100644 --- a/x-pack/plugins/snapshot_restore/public/application/constants/index.ts +++ b/x-pack/plugins/snapshot_restore/public/application/constants/index.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DAY } from '../../shared_imports'; - export const BASE_PATH = ''; export const DEFAULT_SECTION: Section = 'snapshots'; export type Section = 'repositories' | 'snapshots' | 'restore_status' | 'policies'; @@ -89,10 +87,10 @@ export const REMOVE_INDEX_SETTINGS_SUGGESTIONS: string[] = INDEX_SETTING_SUGGEST ); export const DEFAULT_POLICY_SCHEDULE = '0 30 1 * * ?'; -export const DEFAULT_POLICY_FREQUENCY = DAY; +export const DEFAULT_POLICY_FREQUENCY = 'DAY'; export const DEFAULT_RETENTION_SCHEDULE = '0 30 1 * * ?'; -export const DEFAULT_RETENTION_FREQUENCY = DAY; +export const DEFAULT_RETENTION_FREQUENCY = 'DAY'; // UI Metric constants export const UIM_APP_NAME = 'snapshot_restore'; diff --git a/x-pack/plugins/snapshot_restore/public/shared_imports.ts b/x-pack/plugins/snapshot_restore/public/shared_imports.ts index bd1c0e0cd395b..411ec8627c726 100644 --- a/x-pack/plugins/snapshot_restore/public/shared_imports.ts +++ b/x-pack/plugins/snapshot_restore/public/shared_imports.ts @@ -7,8 +7,8 @@ export { AuthorizationProvider, CronEditor, - DAY, Error, + Frequency, NotAuthorizedSection, SectionError, sendRequest, diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx index 54960fba731d2..bc26d6f132522 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx @@ -7,7 +7,7 @@ import { EuiButton, EuiCheckboxProps } from '@elastic/eui'; import { ReactWrapper } from 'enzyme'; import React from 'react'; -import { wait } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { mountWithIntl } from '@kbn/test/jest'; import { ConfirmAlterActiveSpaceModal } from './confirm_alter_active_space_modal'; @@ -70,7 +70,7 @@ describe('ManageSpacePage', () => { /> ); - await wait(() => { + await waitFor(() => { wrapper.update(); expect(wrapper.find('input[name="name"]')).toHaveLength(1); }); @@ -132,7 +132,7 @@ describe('ManageSpacePage', () => { /> ); - await wait(() => { + await waitFor(() => { wrapper.update(); expect(spacesManager.getSpace).toHaveBeenCalledWith('existing-space'); }); @@ -185,7 +185,7 @@ describe('ManageSpacePage', () => { /> ); - await wait(() => { + await waitFor(() => { wrapper.update(); expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { title: 'Error loading available features', @@ -223,7 +223,7 @@ describe('ManageSpacePage', () => { /> ); - await wait(() => { + await waitFor(() => { wrapper.update(); expect(spacesManager.getSpace).toHaveBeenCalledWith('my-space'); }); @@ -285,7 +285,7 @@ describe('ManageSpacePage', () => { /> ); - await wait(() => { + await waitFor(() => { wrapper.update(); expect(spacesManager.getSpace).toHaveBeenCalledWith('my-space'); }); diff --git a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx index b08c1c834ac4f..e841d3efc828c 100644 --- a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx +++ b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx @@ -13,7 +13,7 @@ import { SpacesManager } from '../spaces_manager'; import { NavControlPopover } from './nav_control_popover'; import { EuiHeaderSectionItemButton } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; -import { wait } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; describe('NavControlPopover', () => { it('renders without crashing', () => { @@ -65,7 +65,7 @@ describe('NavControlPopover', () => { wrapper.find(EuiHeaderSectionItemButton).simulate('click'); // Wait for `getSpaces` promise to resolve - await wait(() => { + await waitFor(() => { wrapper.update(); expect(wrapper.find(SpaceAvatar)).toHaveLength(3); }); diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts index d1a8e93bff929..945a2bdbf6daf 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts @@ -18,6 +18,9 @@ import { } from 'src/core/server/mocks'; import { copySavedObjectsToSpacesFactory } from './copy_to_spaces'; +// Mock out circular dependency +jest.mock('../../../../../../src/core/server/saved_objects/es_query', () => {}); + jest.mock('../../../../../../src/core/server', () => { return { ...(jest.requireActual('../../../../../../src/core/server') as Record), diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts index f1b475ee9d1b3..0e54412ee61ae 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts @@ -18,6 +18,9 @@ import { } from 'src/core/server/mocks'; import { resolveCopySavedObjectsToSpacesConflictsFactory } from './resolve_copy_conflicts'; +// Mock out circular dependency +jest.mock('../../../../../../src/core/server/saved_objects/es_query', () => {}); + jest.mock('../../../../../../src/core/server', () => { return { ...(jest.requireActual('../../../../../../src/core/server') as Record), diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index cb81476454cd3..c8b25bf3cf7fa 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -27,6 +27,10 @@ import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service. import { initCopyToSpacesApi } from './copy_to_space'; import { spacesConfig } from '../../../lib/__fixtures__'; import { ObjectType } from '@kbn/config-schema'; + +// Mock out circular dependency +jest.mock('../../../../../../../src/core/server/saved_objects/es_query', () => {}); + jest.mock('../../../../../../../src/core/server', () => { return { ...(jest.requireActual('../../../../../../../src/core/server') as Record), diff --git a/x-pack/plugins/stack_alerts/common/config.ts b/x-pack/plugins/stack_alerts/common/config.ts index 88d4699027425..a1ec8a1e1c454 100644 --- a/x-pack/plugins/stack_alerts/common/config.ts +++ b/x-pack/plugins/stack_alerts/common/config.ts @@ -8,7 +8,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), - enableGeoAlerts: schema.boolean({ defaultValue: false }), + enableGeoAlerting: schema.boolean({ defaultValue: false }), }); export type Config = TypeOf; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/index.ts index 9d611aefb738b..1a9710eb08eb0 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/index.ts @@ -17,7 +17,7 @@ export function registerAlertTypes({ alertTypeRegistry: TriggersAndActionsUIPublicPluginSetup['alertTypeRegistry']; config: Config; }) { - if (config.enableGeoAlerts) { + if (config.enableGeoAlerting) { alertTypeRegistry.register(getGeoThresholdAlertType()); alertTypeRegistry.register(getGeoContainmentAlertType()); } diff --git a/x-pack/plugins/stack_alerts/server/feature.ts b/x-pack/plugins/stack_alerts/server/feature.ts index 4ac0bc43adcd7..448e1e698858b 100644 --- a/x-pack/plugins/stack_alerts/server/feature.ts +++ b/x-pack/plugins/stack_alerts/server/feature.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; import { GEO_THRESHOLD_ID as GeoThreshold } from './alert_types/geo_threshold/alert_type'; +import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containment/alert_type'; import { STACK_ALERTS_FEATURE_ID } from '../common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -20,7 +21,7 @@ export const BUILT_IN_ALERTS_FEATURE = { management: { insightsAndAlerting: ['triggersActions'], }, - alerting: [IndexThreshold, GeoThreshold], + alerting: [IndexThreshold, GeoThreshold, GeoContainment], privileges: { all: { app: [], @@ -29,7 +30,7 @@ export const BUILT_IN_ALERTS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, alerting: { - all: [IndexThreshold, GeoThreshold], + all: [IndexThreshold, GeoThreshold, GeoContainment], read: [], }, savedObject: { @@ -47,7 +48,7 @@ export const BUILT_IN_ALERTS_FEATURE = { }, alerting: { all: [], - read: [IndexThreshold, GeoThreshold], + read: [IndexThreshold, GeoThreshold, GeoContainment], }, savedObject: { all: [], diff --git a/x-pack/plugins/stack_alerts/server/index.ts b/x-pack/plugins/stack_alerts/server/index.ts index 3ef8db33983de..08197b368d9d9 100644 --- a/x-pack/plugins/stack_alerts/server/index.ts +++ b/x-pack/plugins/stack_alerts/server/index.ts @@ -11,13 +11,13 @@ export { ID as INDEX_THRESHOLD_ID } from './alert_types/index_threshold/alert_ty export const config: PluginConfigDescriptor = { exposeToBrowser: { - enableGeoAlerts: true, + enableGeoAlerting: true, }, schema: configSchema, deprecations: ({ renameFromRoot }) => [ renameFromRoot( 'xpack.triggers_actions_ui.enableGeoTrackingThresholdAlert', - 'xpack.stack_alerts.enableGeoAlerts' + 'xpack.stack_alerts.enableGeoAlerting' ), ], }; diff --git a/x-pack/plugins/transform/server/client/elasticsearch_transform.ts b/x-pack/plugins/transform/server/client/elasticsearch_transform.ts deleted file mode 100644 index a17eb1416408a..0000000000000 --- a/x-pack/plugins/transform/server/client/elasticsearch_transform.ts +++ /dev/null @@ -1,144 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -export const elasticsearchJsPlugin = (Client: any, config: any, components: any) => { - const ca = components.clientAction.factory; - - Client.prototype.transform = components.clientAction.namespaceFactory(); - const transform = Client.prototype.transform.prototype; - - // Currently the endpoint uses a default size of 100 unless a size is supplied. - // So until paging is supported in the UI, explicitly supply a size of 1000 - // to match the max number of docs that the endpoint can return. - transform.getTransforms = ca({ - urls: [ - { - fmt: '/_transform/<%=transformId%>', - req: { - transformId: { - type: 'string', - }, - }, - }, - { - fmt: '/_transform/_all?size=1000', - }, - ], - method: 'GET', - }); - - transform.getTransformsStats = ca({ - urls: [ - { - fmt: '/_transform/<%=transformId%>/_stats', - req: { - transformId: { - type: 'string', - }, - }, - }, - { - // Currently the endpoint uses a default size of 100 unless a size is supplied. - // So until paging is supported in the UI, explicitly supply a size of 1000 - // to match the max number of docs that the endpoint can return. - fmt: '/_transform/_all/_stats?size=1000', - }, - ], - method: 'GET', - }); - - transform.createTransform = ca({ - urls: [ - { - fmt: '/_transform/<%=transformId%>', - req: { - transformId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'PUT', - }); - - transform.updateTransform = ca({ - urls: [ - { - fmt: '/_transform/<%=transformId%>/_update', - req: { - transformId: { - type: 'string', - }, - }, - }, - ], - needBody: true, - method: 'POST', - }); - - transform.deleteTransform = ca({ - urls: [ - { - fmt: '/_transform/<%=transformId%>?&force=<%=force%>', - req: { - transformId: { - type: 'string', - }, - force: { - type: 'boolean', - }, - }, - }, - ], - method: 'DELETE', - }); - - transform.getTransformsPreview = ca({ - urls: [ - { - fmt: '/_transform/_preview', - }, - ], - needBody: true, - method: 'POST', - }); - - transform.startTransform = ca({ - urls: [ - { - fmt: '/_transform/<%=transformId%>/_start', - req: { - transformId: { - type: 'string', - }, - }, - }, - ], - method: 'POST', - }); - - transform.stopTransform = ca({ - urls: [ - { - fmt: - '/_transform/<%=transformId%>/_stop?&force=<%=force%>&wait_for_completion=<%waitForCompletion%>', - req: { - transformId: { - type: 'string', - }, - force: { - type: 'boolean', - }, - waitForCompletion: { - type: 'boolean', - }, - }, - }, - ], - method: 'POST', - }); -}; diff --git a/x-pack/plugins/transform/server/plugin.ts b/x-pack/plugins/transform/server/plugin.ts index 988750f70efe0..987028dcacf05 100644 --- a/x-pack/plugins/transform/server/plugin.ts +++ b/x-pack/plugins/transform/server/plugin.ts @@ -4,30 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { - CoreSetup, - ILegacyCustomClusterClient, - Plugin, - ILegacyScopedClusterClient, - Logger, - PluginInitializerContext, -} from 'src/core/server'; +import { CoreSetup, Plugin, Logger, PluginInitializerContext } from 'src/core/server'; import { LicenseType } from '../../licensing/common/types'; -import { elasticsearchJsPlugin } from './client/elasticsearch_transform'; import { Dependencies } from './types'; import { ApiRoutes } from './routes'; import { License } from './services'; -declare module 'kibana/server' { - interface RequestHandlerContext { - transform?: { - dataClient: ILegacyScopedClusterClient; - }; - } -} - const basicLicense: LicenseType = 'basic'; const PLUGIN = { @@ -39,18 +23,10 @@ const PLUGIN = { }), }; -async function getCustomEsClient(getStartServices: CoreSetup['getStartServices']) { - const [core] = await getStartServices(); - return core.elasticsearch.legacy.createClient('transform', { - plugins: [elasticsearchJsPlugin], - }); -} - export class TransformServerPlugin implements Plugin<{}, void, any, any> { private readonly apiRoutes: ApiRoutes; private readonly license: License; private readonly logger: Logger; - private transformESClient?: ILegacyCustomClusterClient; constructor(initContext: PluginInitializerContext) { this.logger = initContext.logger.get(); @@ -58,7 +34,10 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { this.license = new License(); } - setup({ http, getStartServices }: CoreSetup, { licensing, features }: Dependencies): {} { + setup( + { http, getStartServices, elasticsearch }: CoreSetup, + { licensing, features }: Dependencies + ): {} { const router = http.createRouter(); this.license.setup( @@ -94,23 +73,10 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { license: this.license, }); - // Can access via new platform router's handler function 'context' parameter - context.transform.client - http.registerRouteHandlerContext('transform', async (context, request) => { - this.transformESClient = - this.transformESClient ?? (await getCustomEsClient(getStartServices)); - return { - dataClient: this.transformESClient.asScoped(request), - }; - }); - return {}; } start() {} - stop() { - if (this.transformESClient) { - this.transformESClient.close(); - } - } + stop() {} } diff --git a/x-pack/plugins/transform/server/routes/api/error_utils.ts b/x-pack/plugins/transform/server/routes/api/error_utils.ts index cf388f3c8ca08..fe50830cd24f2 100644 --- a/x-pack/plugins/transform/server/routes/api/error_utils.ts +++ b/x-pack/plugins/transform/server/routes/api/error_utils.ts @@ -76,7 +76,7 @@ export function fillResultsWithTimeouts({ results, id, items, action }: Params) } export function wrapError(error: any): CustomHttpResponseOptions { - const boom = Boom.isBoom(error) ? error : Boom.boomify(error, { statusCode: error.status }); + const boom = Boom.isBoom(error) ? error : Boom.boomify(error, { statusCode: error.statusCode }); return { body: boom, headers: boom.output.headers, @@ -109,14 +109,16 @@ function extractCausedByChain( * @return Object Boom error response */ export function wrapEsError(err: any, statusCodeToMessageMap: Record = {}) { - const { statusCode, response } = err; + const { + meta: { body, statusCode }, + } = err; const { error: { root_cause = [], // eslint-disable-line @typescript-eslint/naming-convention caused_by = {}, // eslint-disable-line @typescript-eslint/naming-convention } = {}, - } = JSON.parse(response); + } = body; // If no custom message if specified for the error's status code, just // wrap the error as a Boom error response, include the additional information from ES, and return it @@ -130,6 +132,12 @@ export function wrapEsError(err: any, statusCodeToMessageMap: Record { - const options = {}; + license.guardApiRoute(async (ctx, req, res) => { try { - const transforms = await getTransforms( - options, - ctx.transform!.dataClient.callAsCurrentUser - ); - return res.ok({ body: transforms }); + const { body } = await ctx.core.elasticsearch.client.asCurrentUser.transform.getTransform({ + size: 1000, + ...req.params, + }); + return res.ok({ body }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); } @@ -113,13 +107,11 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { }, license.guardApiRoute(async (ctx, req, res) => { const { transformId } = req.params; - const options = transformId !== undefined ? { transformId } : {}; try { - const transforms = await getTransforms( - options, - ctx.transform!.dataClient.callAsCurrentUser - ); - return res.ok({ body: transforms }); + const { body } = await ctx.core.elasticsearch.client.asCurrentUser.transform.getTransform({ + transform_id: transformId, + }); + return res.ok({ body }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); } @@ -135,18 +127,21 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { */ router.get( { path: addBasePath('transforms/_stats'), validate: false }, - license.guardApiRoute(async (ctx, req, res) => { - const options = {}; - try { - const stats = await ctx.transform!.dataClient.callAsCurrentUser( - 'transform.getTransformsStats', - options - ); - return res.ok({ body: stats }); - } catch (e) { - return res.customError(wrapError(wrapEsError(e))); + license.guardApiRoute( + async (ctx, req, res) => { + try { + const { + body, + } = await ctx.core.elasticsearch.client.asCurrentUser.transform.getTransformStats({ + size: 1000, + transform_id: '_all', + }); + return res.ok({ body }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } } - }) + ) ); /** @@ -165,15 +160,13 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { }, license.guardApiRoute(async (ctx, req, res) => { const { transformId } = req.params; - const options = { - ...(transformId !== undefined ? { transformId } : {}), - }; try { - const stats = await ctx.transform!.dataClient.callAsCurrentUser( - 'transform.getTransformsStats', - options - ); - return res.ok({ body: stats }); + const { + body, + } = await ctx.core.elasticsearch.client.asCurrentUser.transform.getTransformStats({ + transform_id: transformId, + }); + return res.ok({ body }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); } @@ -208,12 +201,14 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { errors: [], }; - await ctx - .transform!.dataClient.callAsCurrentUser('transform.createTransform', { + await ctx.core.elasticsearch.client.asCurrentUser.transform + .putTransform({ body: req.body, - transformId, + transform_id: transformId, + }) + .then(() => { + response.transformsCreated.push({ transform: transformId }); }) - .then(() => response.transformsCreated.push({ transform: transformId })) .catch((e) => response.errors.push({ id: transformId, @@ -249,11 +244,14 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { const { transformId } = req.params; try { + const { + body, + } = await ctx.core.elasticsearch.client.asCurrentUser.transform.updateTransform({ + body: req.body, + transform_id: transformId, + }); return res.ok({ - body: (await ctx.transform!.dataClient.callAsCurrentUser('transform.updateTransform', { - body: req.body, - transformId, - })) as PostTransformsUpdateResponseSchema, + body, }); } catch (e) { return res.customError(wrapError(e)); @@ -381,9 +379,8 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { }, license.guardApiRoute(async (ctx, req, res) => { try { - return res.ok({ - body: await ctx.transform!.dataClient.callAsCurrentUser('search', req.body), - }); + const { body } = await ctx.core.elasticsearch.client.asCurrentUser.search(req.body); + return res.ok({ body }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); } @@ -391,13 +388,6 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { ); } -const getTransforms = async ( - options: { transformId?: string }, - callAsCurrentUser: LegacyAPICaller -): Promise => { - return await callAsCurrentUser('transform.getTransforms', options); -}; - async function getIndexPatternId( indexName: string, savedObjectsClient: SavedObjectsClientContract @@ -452,11 +442,10 @@ async function deleteTransforms( } // Grab destination index info to delete try { - const transformConfigs = await getTransforms( - { transformId }, - ctx.transform!.dataClient.callAsCurrentUser - ); - const transformConfig = transformConfigs.transforms[0]; + const { body } = await ctx.core.elasticsearch.client.asCurrentUser.transform.getTransform({ + transform_id: transformId, + }); + const transformConfig = body.transforms[0]; destinationIndex = Array.isArray(transformConfig.dest.index) ? transformConfig.dest.index[0] : transformConfig.dest.index; @@ -468,6 +457,7 @@ async function deleteTransforms( destIndexPatternDeleted, destinationIndex, }; + // No need to perform further delete attempts continue; } @@ -476,7 +466,7 @@ async function deleteTransforms( try { // If user does have privilege to delete the index, then delete the index // if no permission then return 403 forbidden - await ctx.transform!.dataClient.callAsCurrentUser('indices.delete', { + await ctx.core.elasticsearch.client.asCurrentUser.indices.delete({ index: destinationIndex, }); destIndexDeleted.success = true; @@ -502,14 +492,14 @@ async function deleteTransforms( } try { - await ctx.transform!.dataClient.callAsCurrentUser('transform.deleteTransform', { - transformId, + await ctx.core.elasticsearch.client.asCurrentUser.transform.deleteTransform({ + transform_id: transformId, force: shouldForceDelete && needToForceDelete, }); transformDeleted.success = true; } catch (deleteTransformJobError) { transformDeleted.error = wrapError(deleteTransformJobError); - if (transformDeleted.error.statusCode === 403) { + if (deleteTransformJobError.statusCode === 403) { return response.forbidden(); } } @@ -541,11 +531,10 @@ const previewTransformHandler: RequestHandler< PostTransformsPreviewRequestSchema > = async (ctx, req, res) => { try { - return res.ok({ - body: await ctx.transform!.dataClient.callAsCurrentUser('transform.getTransformsPreview', { - body: req.body, - }), + const { body } = await ctx.core.elasticsearch.client.asCurrentUser.transform.previewTransform({ + body: req.body, }); + return res.ok({ body }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); } @@ -559,8 +548,9 @@ const startTransformsHandler: RequestHandler< const transformsInfo = req.body; try { + const body = await startTransforms(transformsInfo, ctx.core.elasticsearch.client.asCurrentUser); return res.ok({ - body: await startTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + body, }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); @@ -569,14 +559,16 @@ const startTransformsHandler: RequestHandler< async function startTransforms( transformsInfo: StartTransformsRequestSchema, - callAsCurrentUser: LegacyAPICaller + esClient: ElasticsearchClient ) { const results: StartTransformsResponseSchema = {}; for (const transformInfo of transformsInfo) { const transformId = transformInfo.id; try { - await callAsCurrentUser('transform.startTransform', { transformId }); + await esClient.transform.startTransform({ + transform_id: transformId, + }); results[transformId] = { success: true }; } catch (e) { if (isRequestTimeout(e)) { @@ -602,7 +594,7 @@ const stopTransformsHandler: RequestHandler< try { return res.ok({ - body: await stopTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + body: await stopTransforms(transformsInfo, ctx.core.elasticsearch.client.asCurrentUser), }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); @@ -611,21 +603,21 @@ const stopTransformsHandler: RequestHandler< async function stopTransforms( transformsInfo: StopTransformsRequestSchema, - callAsCurrentUser: LegacyAPICaller + esClient: ElasticsearchClient ) { const results: StopTransformsResponseSchema = {}; for (const transformInfo of transformsInfo) { const transformId = transformInfo.id; try { - await callAsCurrentUser('transform.stopTransform', { - transformId, + await esClient.transform.stopTransform({ + transform_id: transformId, force: transformInfo.state !== undefined ? transformInfo.state === TRANSFORM_STATE.FAILED : false, - waitForCompletion: true, - } as StopOptions); + wait_for_completion: true, + }); results[transformId] = { success: true }; } catch (e) { if (isRequestTimeout(e)) { diff --git a/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts index 8c95ab5c786ed..3563775b26f3c 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts @@ -77,7 +77,7 @@ export function registerTransformsAuditMessagesRoutes({ router, license }: Route } try { - const resp = await ctx.transform!.dataClient.callAsCurrentUser('search', { + const { body: resp } = await ctx.core.elasticsearch.client.asCurrentUser.search({ index: ML_DF_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, size: SIZE, diff --git a/x-pack/plugins/transform/server/routes/index.ts b/x-pack/plugins/transform/server/routes/index.ts index 4f35b094017a4..36aea6677b815 100644 --- a/x-pack/plugins/transform/server/routes/index.ts +++ b/x-pack/plugins/transform/server/routes/index.ts @@ -20,7 +20,4 @@ export class ApiRoutes { registerPrivilegesRoute(dependencies); registerTransformsRoutes(dependencies); } - - start() {} - stop() {} } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1f36a5a71537b..eb1fd694114ce 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11302,7 +11302,6 @@ "xpack.maps.source.emsTile.settingsTitle": "ベースマップ", "xpack.maps.source.emsTileDescription": "Elastic Maps Service のマップタイル", "xpack.maps.source.emsTileTitle": "タイル", - "xpack.maps.source.esAggSource.topTermLabel": "トップ {fieldName}", "xpack.maps.source.esGeoGrid.geofieldLabel": "地理空間フィールド", "xpack.maps.source.esGeoGrid.geofieldPlaceholder": "ジオフィールドを選択", "xpack.maps.source.esGeoGrid.gridRectangleDropdownOption": "グリッド", @@ -17915,7 +17914,6 @@ "xpack.securitySolution.resolver.eventDescription.networkEventLabel": "{ networkDirection } { forwardedIP }", "xpack.securitySolution.resolver.eventDescription.registryKeyLabel": "{ registryKey }", "xpack.securitySolution.resolver.eventDescription.registryPathLabel": "{ registryPath }", - "xpack.securitySolution.resolver.node_icon": "{running, select, true {実行中のプロセス} false {終了したプロセス}}", "xpack.securitySolution.resolver.panel.copyToClipboard": "クリップボードにコピー", "xpack.securitySolution.resolver.panel.eventDetail.requestError": "イベント詳細を取得できませんでした", "xpack.securitySolution.resolver.panel.nodeList.title": "すべてのプロセスイベント", @@ -18134,13 +18132,9 @@ "xpack.securitySolution.trustedapps.list.columns.actions": "アクション", "xpack.securitySolution.trustedapps.list.pageTitle": "信頼できるアプリケーション", "xpack.securitySolution.trustedapps.list.totalCount": "{totalItemCount, plural, one {#個の信頼できるアプリケーション} other {#個の信頼できるアプリケーション}}", - "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field": "フィールド", "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.hash": "ハッシュ", "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.path": "パス", - "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.operator": "演算子", - "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.operator.is": "is", "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.removeLabel": "エントリを削除", - "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.value": "値", "xpack.securitySolution.trustedapps.logicalConditionBuilder.group.andOperator": "AND", "xpack.securitySolution.trustedapps.logicalConditionBuilder.noEntries": "条件が定義されていません", "xpack.securitySolution.trustedapps.noResults": "項目が見つかりません", @@ -18377,7 +18371,6 @@ "xpack.snapshotRestore.policyForm.stepLogistics.snapshotNameDescription": "スナップショットの名前です。それぞれの名前に自動的に追加される固有の識別子です。", "xpack.snapshotRestore.policyForm.stepLogistics.snapshotNameDescriptionTitle": "スナップショット名", "xpack.snapshotRestore.policyForm.stepLogisticsTitle": "ロジスティクス", - "xpack.snapshotRestore.policyForm.stepRetention.countDescription": "クラスターに格納するスナップショットの最少数と最大数。", "xpack.snapshotRestore.policyForm.stepRetention.countTitle": "保存するスナップショット", "xpack.snapshotRestore.policyForm.stepRetention.docsButtonLabel": "スナップショット保存ドキュメント", "xpack.snapshotRestore.policyForm.stepRetention.expirationDescription": "スナップショットの削除までに待つ時間です。", @@ -20323,8 +20316,6 @@ "xpack.uptime.breadcrumbs.overviewBreadcrumbText": "アップタイム", "xpack.uptime.certificates.heading": "TLS証明書({total})", "xpack.uptime.certificates.refresh": "更新", - "xpack.uptime.certificates.returnToOverviewLinkLabel": "概要に戻る", - "xpack.uptime.certificates.settingsLinkLabel": "設定", "xpack.uptime.certs.expired": "期限切れ", "xpack.uptime.certs.expires": "有効期限", "xpack.uptime.certs.expireSoon": "まもなく期限切れ", @@ -20462,7 +20453,6 @@ "xpack.uptime.monitorList.table.description": "列にステータス、名前、URL、IP、ダウンタイム履歴、統合が入力されたモニターステータス表です。この表は現在 {length} 項目を表示しています。", "xpack.uptime.monitorList.table.url.name": "Url", "xpack.uptime.monitorList.tlsColumnLabel": "TLS証明書", - "xpack.uptime.monitorList.viewCertificateTitle": "証明書ステータス", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "ミリ秒単位の監視時間", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "監視ステータス", "xpack.uptime.monitorStatusBar.loadingMessage": "読み込み中…", @@ -20528,7 +20518,6 @@ "xpack.uptime.settings.error.couldNotSave": "設定を保存できませんでした!", "xpack.uptime.settings.invalid.error": "値は0よりも大きい値でなければなりません。", "xpack.uptime.settings.invalid.nanError": "値は整数でなければなりません。", - "xpack.uptime.settings.returnToOverviewLinkLabel": "概要に戻る", "xpack.uptime.settings.saveSuccess": "設定が保存されました。", "xpack.uptime.settingsBreadcrumbText": "設定", "xpack.uptime.snapshot.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total}個中{down}個のモニターがダウンしています。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3151b863cc4a1..8ad261449854e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11315,7 +11315,6 @@ "xpack.maps.source.emsTile.settingsTitle": "Basemap", "xpack.maps.source.emsTileDescription": "Elastic 地图服务的地图磁贴", "xpack.maps.source.emsTileTitle": "磁贴", - "xpack.maps.source.esAggSource.topTermLabel": "热门{fieldName}", "xpack.maps.source.esGeoGrid.geofieldLabel": "地理空间字段", "xpack.maps.source.esGeoGrid.geofieldPlaceholder": "选择地理字段", "xpack.maps.source.esGeoGrid.gridRectangleDropdownOption": "网格", @@ -17933,7 +17932,6 @@ "xpack.securitySolution.resolver.eventDescription.networkEventLabel": "{ networkDirection } { forwardedIP }", "xpack.securitySolution.resolver.eventDescription.registryKeyLabel": "{ registryKey }", "xpack.securitySolution.resolver.eventDescription.registryPathLabel": "{ registryPath }", - "xpack.securitySolution.resolver.node_icon": "{running, select, true {正在运行的进程} false {已终止的进程}}", "xpack.securitySolution.resolver.panel.copyToClipboard": "复制到剪贴板", "xpack.securitySolution.resolver.panel.eventDetail.requestError": "无法检索事件详情", "xpack.securitySolution.resolver.panel.nodeList.title": "所有进程事件", @@ -18152,13 +18150,9 @@ "xpack.securitySolution.trustedapps.list.columns.actions": "操作", "xpack.securitySolution.trustedapps.list.pageTitle": "受信任的应用程序", "xpack.securitySolution.trustedapps.list.totalCount": "{totalItemCount, plural, one {# 个受信任的应用程序} other {# 个受信任的应用程序}}", - "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field": "字段", "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.hash": "哈希", "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.path": "路径", - "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.operator": "运算符", - "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.operator.is": "is", "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.removeLabel": "移除条目", - "xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.value": "值", "xpack.securitySolution.trustedapps.logicalConditionBuilder.group.andOperator": "AND", "xpack.securitySolution.trustedapps.logicalConditionBuilder.noEntries": "未定义条件", "xpack.securitySolution.trustedapps.noResults": "找不到项目", @@ -18395,7 +18389,6 @@ "xpack.snapshotRestore.policyForm.stepLogistics.snapshotNameDescription": "快照的名称。唯一标识符将自动添加到每个名称中。", "xpack.snapshotRestore.policyForm.stepLogistics.snapshotNameDescriptionTitle": "快照名称", "xpack.snapshotRestore.policyForm.stepLogisticsTitle": "运筹", - "xpack.snapshotRestore.policyForm.stepRetention.countDescription": "在您的集群中要存储的最小和最大快照数目。", "xpack.snapshotRestore.policyForm.stepRetention.countTitle": "要保留的快照", "xpack.snapshotRestore.policyForm.stepRetention.docsButtonLabel": "快照保留文档", "xpack.snapshotRestore.policyForm.stepRetention.expirationDescription": "删除快照前要等候的时间。", @@ -20342,8 +20335,6 @@ "xpack.uptime.breadcrumbs.overviewBreadcrumbText": "运行时间", "xpack.uptime.certificates.heading": "TLS 证书 ({total})", "xpack.uptime.certificates.refresh": "刷新", - "xpack.uptime.certificates.returnToOverviewLinkLabel": "返回到概览", - "xpack.uptime.certificates.settingsLinkLabel": "设置", "xpack.uptime.certs.expired": "已过期", "xpack.uptime.certs.expires": "过期", "xpack.uptime.certs.expireSoon": "即将过期", @@ -20481,7 +20472,6 @@ "xpack.uptime.monitorList.table.description": "具有“状态”、“名称”、“URL”、“IP”、“中断历史记录”和“集成”列的“监测状态”表。该表当前显示 {length} 个项目。", "xpack.uptime.monitorList.table.url.name": "URL", "xpack.uptime.monitorList.tlsColumnLabel": "TLS 证书", - "xpack.uptime.monitorList.viewCertificateTitle": "证书状态", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "监测持续时间(毫秒)", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "检测状态", "xpack.uptime.monitorStatusBar.loadingMessage": "正在加载……", @@ -20547,7 +20537,6 @@ "xpack.uptime.settings.error.couldNotSave": "无法保存设置!", "xpack.uptime.settings.invalid.error": "值必须大于 0。", "xpack.uptime.settings.invalid.nanError": "值必须为整数。", - "xpack.uptime.settings.returnToOverviewLinkLabel": "返回到概览", "xpack.uptime.settings.saveSuccess": "设置已保存!", "xpack.uptime.settingsBreadcrumbText": "设置", "xpack.uptime.snapshot.donutChart.ariaLabel": "显示当前状态的饼图。{total} 个监测中有 {down} 个已关闭。", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 5b2c8bd63a2f5..9de3ae21a8ef7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -11,6 +11,11 @@ import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, Alert, AlertAction } from '../../../types'; import ActionForm from './action_form'; import { useKibana } from '../../../common/lib/kibana'; +import { + RecoveredActionGroup, + isActionGroupDisabledForActionTypeId, +} from '../../../../../alerts/common'; + jest.mock('../../../common/lib/kibana'); jest.mock('../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), @@ -65,6 +70,21 @@ describe('action_form', () => { actionParamsFields: mockedActionParamsFields, }; + const disabledByActionType = { + id: '.jira', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: mockedActionParamsFields, + }; + const disabledByLicenseActionType = { id: 'disabled-by-license', iconClass: 'test', @@ -112,7 +132,7 @@ describe('action_form', () => { const useKibanaMock = useKibana as jest.Mocked; describe('action_form in alert', () => { - async function setup(customActions?: AlertAction[]) { + async function setup(customActions?: AlertAction[], customRecoveredActionGroup?: string) { const actionTypeRegistry = actionTypeRegistryMock.create(); const { loadAllActions } = jest.requireMock('../../lib/action_connector_api'); @@ -159,6 +179,14 @@ describe('action_form', () => { }, isPreconfigured: false, }, + { + secrets: {}, + id: '.jira', + actionTypeId: disabledByActionType.id, + name: 'Connector with disabled action group', + config: {}, + isPreconfigured: false, + }, ]); const mocks = coreMock.createSetup(); const [ @@ -179,6 +207,7 @@ describe('action_form', () => { actionType, disabledByConfigActionType, disabledByLicenseActionType, + disabledByActionType, preconfiguredOnly, actionTypeWithoutParams, ]); @@ -223,12 +252,24 @@ describe('action_form', () => { context: [{ name: 'contextVar', description: 'context var1' }], }} defaultActionGroupId={'default'} + isActionGroupDisabledForActionType={(actionGroupId: string, actionTypeId: string) => { + const recoveryActionGroupId = customRecoveredActionGroup + ? customRecoveredActionGroup + : 'recovered'; + return isActionGroupDisabledForActionTypeId( + actionGroupId === recoveryActionGroupId ? RecoveredActionGroup.id : actionGroupId, + actionTypeId + ); + }} setActionIdByIndex={(id: string, index: number) => { initialAlert.actions[index].id = id; }} actionGroups={[ { id: 'default', name: 'Default', defaultActionMessage }, - { id: 'recovered', name: 'Recovered' }, + { + id: customRecoveredActionGroup ? customRecoveredActionGroup : 'recovered', + name: customRecoveredActionGroup ? 'I feel better' : 'Recovered', + }, ]} setActionGroupIdByIndex={(group: string, index: number) => { initialAlert.actions[index].group = group; @@ -280,6 +321,14 @@ describe('action_form', () => { enabledInLicense: false, minimumLicenseRequired: 'gold', }, + { + id: '.jira', + name: 'Disabled by action type', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, { id: actionTypeWithoutParams.id, name: 'Action type without params', @@ -342,11 +391,13 @@ describe('action_form', () => { Array [ Object { "data-test-subj": "addNewActionConnectorActionGroup-0-option-default", + "disabled": false, "inputDisplay": "Default", "value": "default", }, Object { "data-test-subj": "addNewActionConnectorActionGroup-0-option-recovered", + "disabled": false, "inputDisplay": "Recovered", "value": "recovered", }, @@ -354,6 +405,77 @@ describe('action_form', () => { `); }); + it('renders disabled action groups for selected action type', async () => { + const wrapper = await setup([ + { + group: 'recovered', + id: 'test', + actionTypeId: disabledByActionType.id, + params: { + message: '', + }, + }, + ]); + const actionOption = wrapper.find(`[data-test-subj=".jira-ActionTypeSelectOption"]`); + actionOption.first().simulate('click'); + const actionGroupsSelect = wrapper.find( + `[data-test-subj="addNewActionConnectorActionGroup-1"]` + ); + expect((actionGroupsSelect.first().props() as any).options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "addNewActionConnectorActionGroup-1-option-default", + "disabled": false, + "inputDisplay": "Default", + "value": "default", + }, + Object { + "data-test-subj": "addNewActionConnectorActionGroup-1-option-recovered", + "disabled": true, + "inputDisplay": "Recovered (Not Currently Supported)", + "value": "recovered", + }, + ] + `); + }); + + it('renders disabled action groups for custom recovered action groups', async () => { + const wrapper = await setup( + [ + { + group: 'iHaveRecovered', + id: 'test', + actionTypeId: disabledByActionType.id, + params: { + message: '', + }, + }, + ], + 'iHaveRecovered' + ); + const actionOption = wrapper.find(`[data-test-subj=".jira-ActionTypeSelectOption"]`); + actionOption.first().simulate('click'); + const actionGroupsSelect = wrapper.find( + `[data-test-subj="addNewActionConnectorActionGroup-1"]` + ); + expect((actionGroupsSelect.first().props() as any).options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "addNewActionConnectorActionGroup-1-option-default", + "disabled": false, + "inputDisplay": "Default", + "value": "default", + }, + Object { + "data-test-subj": "addNewActionConnectorActionGroup-1-option-iHaveRecovered", + "disabled": true, + "inputDisplay": "I feel better (Not Currently Supported)", + "value": "iHaveRecovered", + }, + ] + `); + }); + it('renders available connectors for the selected action type', async () => { const wrapper = await setup(); const actionOption = wrapper.find( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 0337f6879e24a..1cb1a68986192 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -59,6 +59,7 @@ export interface ActionAccordionFormProps { setHasActionsWithBrokenConnector?: (value: boolean) => void; actionTypeRegistry: ActionTypeRegistryContract; getDefaultActionParams?: DefaultActionParamsGetter; + isActionGroupDisabledForActionType?: (actionGroupId: string, actionTypeId: string) => boolean; } interface ActiveActionConnectorState { @@ -81,6 +82,7 @@ export const ActionForm = ({ setHasActionsWithBrokenConnector, actionTypeRegistry, getDefaultActionParams, + isActionGroupDisabledForActionType, }: ActionAccordionFormProps) => { const { http, @@ -345,6 +347,7 @@ export const ActionForm = ({ actionGroups={actionGroups} defaultActionMessage={defaultActionMessage} defaultParams={getDefaultActionParams?.(actionItem.actionTypeId, actionItem.group)} + isActionGroupDisabledForActionType={isActionGroupDisabledForActionType} setActionGroupIdByIndex={setActionGroupIdByIndex} onAddConnector={() => { setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index d68f66f373135..9a721b2f2bed0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -60,6 +60,7 @@ export type ActionTypeFormProps = { connectors: ActionConnector[]; actionTypeRegistry: ActionTypeRegistryContract; defaultParams: DefaultActionParams; + isActionGroupDisabledForActionType?: (actionGroupId: string, actionTypeId: string) => boolean; } & Pick< ActionAccordionFormProps, | 'defaultActionGroupId' @@ -94,6 +95,7 @@ export const ActionTypeForm = ({ actionGroups, setActionGroupIdByIndex, actionTypeRegistry, + isActionGroupDisabledForActionType, defaultParams, }: ActionTypeFormProps) => { const { @@ -145,6 +147,28 @@ export const ActionTypeForm = ({ const actionType = actionTypesIndex[actionItem.actionTypeId]; + const actionGroupDisplay = ( + actionGroupId: string, + actionGroupName: string, + actionTypeId: string + ): string => + isActionGroupDisabledForActionType + ? isActionGroupDisabledForActionType(actionGroupId, actionTypeId) + ? i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.addNewActionConnectorActionGroup.display', + { + defaultMessage: '{actionGroupName} (Not Currently Supported)', + values: { actionGroupName }, + } + ) + : actionGroupName + : actionGroupName; + + const isActionGroupDisabled = (actionGroupId: string, actionTypeId: string): boolean => + isActionGroupDisabledForActionType + ? isActionGroupDisabledForActionType(actionGroupId, actionTypeId) + : false; + const optionsList = connectors .filter( (connectorItem) => @@ -191,7 +215,8 @@ export const ActionTypeForm = ({ data-test-subj={`addNewActionConnectorActionGroup-${index}`} options={actionGroups.map(({ id: value, name }) => ({ value, - inputDisplay: name, + inputDisplay: actionGroupDisplay(value, name, actionItem.actionTypeId), + disabled: isActionGroupDisabled(value, actionItem.actionTypeId), 'data-test-subj': `addNewActionConnectorActionGroup-${index}-option-${value}`, }))} valueOfSelected={selectedActionGroup.id} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index c94086a6adab9..3a8835825acd1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -55,7 +55,12 @@ import { } from '../../../types'; import { getTimeOptions } from '../../../common/lib/get_time_options'; import { ActionForm } from '../action_connector_form'; -import { AlertActionParam, ALERTS_FEATURE_ID } from '../../../../../alerts/common'; +import { + AlertActionParam, + ALERTS_FEATURE_ID, + RecoveredActionGroup, + isActionGroupDisabledForActionTypeId, +} from '../../../../../alerts/common'; import { hasAllPrivilege, hasShowActionsCapability } from '../../lib/capabilities'; import { SolutionFilter } from './solution_filter'; import './alert_form.scss'; @@ -192,6 +197,7 @@ export const AlertForm = ({ setDefaultActionGroupId(index.get(alert.alertTypeId)!.defaultActionGroupId); } setAlertTypesIndex(index); + const availableAlertTypesResult = getAvailableAlertTypes(alertTypesResult); setAvailableAlertTypes(availableAlertTypesResult); @@ -331,6 +337,18 @@ export const AlertForm = ({ const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; + const isActionGroupDisabledForActionType = useCallback( + (alertType: AlertType, actionGroupId: string, actionTypeId: string): boolean => { + return isActionGroupDisabledForActionTypeId( + actionGroupId === alertType?.recoveryActionGroup?.id + ? RecoveredActionGroup.id + : actionGroupId, + actionTypeId + ); + }, + [] + ); + const AlertParamsExpressionComponent = alertTypeModel ? alertTypeModel.alertParamsExpression : null; @@ -513,6 +531,9 @@ export const AlertForm = ({ setHasActionsWithBrokenConnector={setHasActionsWithBrokenConnector} messageVariables={selectedAlertType.actionVariables} defaultActionGroupId={defaultActionGroupId} + isActionGroupDisabledForActionType={(actionGroupId: string, actionTypeId: string) => + isActionGroupDisabledForActionType(selectedAlertType, actionGroupId, actionTypeId) + } actionGroups={selectedAlertType.actionGroups.map((actionGroup) => actionGroup.id === selectedAlertType.recoveryActionGroup.id ? { diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx index dab28fb03f4e0..780cb05d31d8d 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { fireEvent, render, wait, cleanup } from '@testing-library/react'; +import { fireEvent, render, waitFor, cleanup } from '@testing-library/react'; import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; import { mockGetTriggerInfo, @@ -50,7 +50,7 @@ test('Allows to manage drilldowns', async () => { ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + await waitFor(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); // no drilldowns in the list expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0); @@ -87,7 +87,7 @@ test('Allows to manage drilldowns', async () => { expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); + await waitFor(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); expect(screen.getByText(name)).toBeVisible(); const editButton = screen.getByText(/edit/i); fireEvent.click(editButton); @@ -105,14 +105,14 @@ test('Allows to manage drilldowns', async () => { fireEvent.click(screen.getByText(/save/i)); expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - await wait(() => screen.getByText(newName)); + await waitFor(() => screen.getByText(newName)); // delete drilldown from edit view fireEvent.click(screen.getByText(/edit/i)); fireEvent.click(screen.getByText(/delete/i)); expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); + await waitFor(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); }); test('Can delete multiple drilldowns', async () => { @@ -123,7 +123,7 @@ test('Can delete multiple drilldowns', async () => { /> ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + await waitFor(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); const createDrilldown = async () => { const oldCount = screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM).length; @@ -136,7 +136,7 @@ test('Can delete multiple drilldowns', async () => { target: { value: 'https://elastic.co' }, }); fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => + await waitFor(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(oldCount + 1) ); }; @@ -151,7 +151,7 @@ test('Can delete multiple drilldowns', async () => { expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); fireEvent.click(screen.getByText(/Delete \(3\)/i)); - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); + await waitFor(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); }); test('Create only mode', async () => { @@ -165,7 +165,7 @@ test('Create only mode', async () => { /> ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'test' }, }); @@ -175,7 +175,7 @@ test('Create only mode', async () => { }); fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => expect(toasts.addSuccess).toBeCalled()); + await waitFor(() => expect(toasts.addSuccess).toBeCalled()); expect(onClose).toBeCalled(); expect(await mockDynamicActionManager.state.get().events.length).toBe(1); }); @@ -189,7 +189,7 @@ test('After switching between action factories state is restored', async () => { /> ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'test' }, }); @@ -210,7 +210,7 @@ test('After switching between action factories state is restored', async () => { expect(screen.getByLabelText(/name/i)).toHaveValue('test'); fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => expect(toasts.addSuccess).toBeCalled()); + await waitFor(() => expect(toasts.addSuccess).toBeCalled()); expect(await (mockDynamicActionManager.state.get().events[0].action.config as any).url).toBe( 'https://elastic.co' ); @@ -230,7 +230,7 @@ test("Error when can't save drilldown changes", async () => { /> ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + await waitFor(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); fireEvent.click(screen.getByText(/Create new/i)); fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'test' }, @@ -240,7 +240,7 @@ test("Error when can't save drilldown changes", async () => { target: { value: 'https://elastic.co' }, }); fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => + await waitFor(() => expect(toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) ); }); @@ -254,7 +254,7 @@ test('Should show drilldown welcome message. Should be able to dismiss it', asyn ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + await waitFor(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); expect(screen.getByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeVisible(); fireEvent.click(screen.getByText(/hide/i)); @@ -268,7 +268,7 @@ test('Should show drilldown welcome message. Should be able to dismiss it', asyn /> ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + await waitFor(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); }); @@ -281,7 +281,7 @@ test('Drilldown type is not shown if no supported trigger', async () => { /> ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); expect(screen.queryByText(/Go to Dashboard/i)).not.toBeInTheDocument(); // dashboard action is not visible, because APPLY_FILTER_TRIGGER not supported expect(screen.getByTestId('selectedActionFactory-Url')).toBeInTheDocument(); }); @@ -295,7 +295,7 @@ test('Can pick a trigger', async () => { /> ); // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); // input drilldown name const name = 'Test name'; @@ -318,6 +318,6 @@ test('Can pick a trigger', async () => { expect(createButton).toBeEnabled(); fireEvent.click(createButton); - await wait(() => expect(toasts.addSuccess).toBeCalled()); + await waitFor(() => expect(toasts.addSuccess).toBeCalled()); expect(mockDynamicActionManager.state.get().events[0].triggers).toEqual(['SELECT_RANGE_TRIGGER']); }); diff --git a/x-pack/plugins/uptime/public/apps/render_app.tsx b/x-pack/plugins/uptime/public/apps/render_app.tsx index c0567ff956ce4..803431dc25b24 100644 --- a/x-pack/plugins/uptime/public/apps/render_app.tsx +++ b/x-pack/plugins/uptime/public/apps/render_app.tsx @@ -21,7 +21,7 @@ export function renderApp( core: CoreStart, plugins: ClientPluginsSetup, startPlugins: ClientPluginsStart, - { element, history }: AppMountParameters + appMountParameters: AppMountParameters ) { const { application: { capabilities }, @@ -47,7 +47,6 @@ export function renderApp( basePath: basePath.get(), darkMode: core.uiSettings.get(DEFAULT_DARK_MODE), commonlyUsedRanges: core.uiSettings.get(DEFAULT_TIMEPICKER_QUICK_RANGES), - history, isApmAvailable: apm, isInfraAvailable: infrastructure, isLogsAvailable: logs, @@ -68,12 +67,13 @@ export function renderApp( ], }), setBadge, + appMountParameters, setBreadcrumbs: core.chrome.setBreadcrumbs, }; - ReactDOM.render(, element); + ReactDOM.render(, appMountParameters.element); return () => { - ReactDOM.unmountComponentAtNode(element); + ReactDOM.unmountComponentAtNode(appMountParameters.element); }; } diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 9bbcde041a794..061398b25e452 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; import { Provider as ReduxProvider } from 'react-redux'; import { Router } from 'react-router-dom'; -import { I18nStart, ChromeBreadcrumb, CoreStart } from 'kibana/public'; +import { I18nStart, ChromeBreadcrumb, CoreStart, AppMountParameters } from 'kibana/public'; import { KibanaContextProvider, RedirectAppLinks, @@ -28,7 +28,7 @@ import { PageRouter } from '../routes'; import { UptimeAlertsFlyoutWrapper } from '../components/overview/alerts'; import { store } from '../state'; import { kibanaService } from '../state/kibana_service'; -import { ScopedHistory } from '../../../../../src/core/public'; +import { ActionMenu } from '../components/common/header/action_menu'; import { EuiThemeProvider } from '../../../observability/public'; export interface UptimeAppColors { @@ -47,7 +47,6 @@ export interface UptimeAppProps { canSave: boolean; core: CoreStart; darkMode: boolean; - history: ScopedHistory; i18n: I18nStart; isApmAvailable: boolean; isInfraAvailable: boolean; @@ -58,6 +57,7 @@ export interface UptimeAppProps { renderGlobalHelpControls(): void; commonlyUsedRanges: CommonlyUsedRange[]; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + appMountParameters: AppMountParameters; } const Application = (props: UptimeAppProps) => { @@ -71,6 +71,7 @@ const Application = (props: UptimeAppProps) => { renderGlobalHelpControls, setBadge, startPlugins, + appMountParameters, } = props; useEffect(() => { @@ -101,7 +102,7 @@ const Application = (props: UptimeAppProps) => { - + @@ -112,6 +113,7 @@ const Application = (props: UptimeAppProps) => {
+
diff --git a/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx b/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.tsx new file mode 100644 index 0000000000000..d0823276f1885 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/certificates/cert_refresh_btn.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHideFor, + EuiShowFor, +} from '@elastic/eui'; +import * as labels from '../../pages/translations'; +import { UptimeRefreshContext } from '../../contexts'; + +export const CertRefreshBtn = () => { + const { refreshApp } = useContext(UptimeRefreshContext); + + return ( + + + + + { + refreshApp(); + }} + data-test-subj="superDatePickerApplyTimeButton" + > + {labels.REFRESH_CERT} + + + + { + refreshApp(); + }} + data-test-subj="superDatePickerApplyTimeButton" + /> + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap similarity index 64% rename from x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap rename to x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap index 7bb578494ab44..05a78624848c6 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/header/__tests__/__snapshots__/page_header.test.tsx.snap @@ -107,97 +107,86 @@ Array [ }
-
-
- -
-
-
- -
@@ -308,7 +297,7 @@ Array [ }
, ] `; @@ -420,16 +409,91 @@ Array [ } +
+
- TestingHeading - +
+ +
+
, ] `; exports[`PageHeader shallow renders without the date picker: page_header_no_date_picker 1`] = ` Array [ -
, -
, - +
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + + +
+
, -
, ] `; diff --git a/x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx b/x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx similarity index 82% rename from x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx rename to x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx index 63d4c24f965d9..0b72cc64f8102 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/page_header.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/__tests__/page_header.test.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { PageHeader } from '../page_header'; -import { renderWithRouter, MountWithReduxProvider } from '../../lib'; +import { renderWithRouter, MountWithReduxProvider } from '../../../../lib'; describe('PageHeader', () => { it('shallow renders with the date picker', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_with_date_picker'); @@ -21,7 +21,7 @@ describe('PageHeader', () => { it('shallow renders without the date picker', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_no_date_picker'); @@ -30,7 +30,7 @@ describe('PageHeader', () => { it('shallow renders extra links', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot('page_header_with_extra_links'); diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx new file mode 100644 index 0000000000000..b59470f66f796 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { HeaderMenuPortal } from '../../../../../observability/public'; +import { AppMountParameters } from '../../../../../../../src/core/public'; + +const ADD_DATA_LABEL = i18n.translate('xpack.uptime.addDataButtonLabel', { + defaultMessage: 'Add data', +}); + +export const ActionMenu = ({ appMountParameters }: { appMountParameters: AppMountParameters }) => { + const kibana = useKibana(); + + return ( + + + + + {ADD_DATA_LABEL} + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx new file mode 100644 index 0000000000000..63bcb6663619d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; +import { useRouteMatch } from 'react-router-dom'; +import { UptimeDatePicker } from '../uptime_date_picker'; +import { SyntheticsCallout } from '../../overview/synthetics_callout'; +import { PageTabs } from './page_tabs'; +import { CERTIFICATES_ROUTE, MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../common/constants'; +import { CertRefreshBtn } from '../../certificates/cert_refresh_btn'; +import { ToggleAlertFlyoutButton } from '../../overview/alerts/alerts_containers'; + +const StyledPicker = styled(EuiFlexItem)` + &&& { + @media only screen and (max-width: 1024px) and (min-width: 868px) { + .euiSuperDatePicker__flexWrapper { + width: 500px; + } + } + @media only screen and (max-width: 880px) { + flex-grow: 1; + .euiSuperDatePicker__flexWrapper { + width: calc(100% + 8px); + } + } + } +`; + +export const PageHeader = () => { + const isCertRoute = useRouteMatch(CERTIFICATES_ROUTE); + const isSettingsRoute = useRouteMatch(SETTINGS_ROUTE); + + const DatePickerComponent = () => + isCertRoute ? ( + + ) : ( + + + + ); + + const isMonRoute = useRouteMatch(MONITOR_ROUTE); + + return ( + <> + + + + + + + + + {!isSettingsRoute && } + + {isMonRoute && } + {!isMonRoute && } + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx b/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx new file mode 100644 index 0000000000000..68df15c52c65e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/page_tabs.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; + +import { EuiTabs, EuiTab } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { CERTIFICATES_ROUTE, OVERVIEW_ROUTE, SETTINGS_ROUTE } from '../../../../common/constants'; + +const tabs = [ + { + id: OVERVIEW_ROUTE, + name: i18n.translate('xpack.uptime.overviewPage.headerText', { + defaultMessage: 'Overview', + description: `The text that will be displayed in the app's heading when the Overview page loads.`, + }), + dataTestSubj: 'uptimeSettingsToOverviewLink', + }, + { + id: CERTIFICATES_ROUTE, + name: 'Certificates', + dataTestSubj: 'uptimeCertificatesLink', + }, + { + id: SETTINGS_ROUTE, + dataTestSubj: 'settings-page-link', + name: i18n.translate('xpack.uptime.page_header.settingsLink', { + defaultMessage: 'Settings', + }), + }, +]; + +export const PageTabs = () => { + const [selectedTabId, setSelectedTabId] = useState(null); + + const history = useHistory(); + + const isOverView = useRouteMatch(OVERVIEW_ROUTE); + const isSettings = useRouteMatch(SETTINGS_ROUTE); + const isCerts = useRouteMatch(CERTIFICATES_ROUTE); + + useEffect(() => { + if (isOverView?.isExact) { + setSelectedTabId(OVERVIEW_ROUTE); + } + if (isCerts) { + setSelectedTabId(CERTIFICATES_ROUTE); + } + if (isSettings) { + setSelectedTabId(SETTINGS_ROUTE); + } + if (!isOverView?.isExact && !isCerts && !isSettings) { + setSelectedTabId(null); + } + }, [isCerts, isSettings, isOverView]); + + const renderTabs = () => { + return tabs.map(({ dataTestSubj, name, id }, index) => ( + setSelectedTabId(id)} + isSelected={id === selectedTabId} + key={index} + data-test-subj={dataTestSubj} + href={history.createHref({ pathname: id })} + > + {name} + + )); + }; + + return ( + + {renderTabs()} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 39f860f76f2bd..bd1aecc9ede48 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -826,26 +826,20 @@ exports[`MonitorList component renders loading state 1`] = ` `; exports[`MonitorList component renders the monitor list 1`] = ` -.c3 { +.c2 { padding-right: 4px; } -.c4 { +.c3 { padding-top: 12px; } -.c1 { - position: absolute; - right: 16px; - top: 16px; -} - .c0 { position: relative; } @media (max-width:574px) { - .c2 { + .c1 { min-width: 230px; } } @@ -936,13 +930,6 @@ exports[`MonitorList component renders the monitor list 1`] = `
- - Certificates status -
-

- TestingHeading -

+
+ + Overview + + + + + Certificates + + + + + Settings + + +