diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index e070baa844ea94..4a59641e29af2a 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -68,6 +68,7 @@ enabled: - test/functional/apps/dashboard/group3/config.ts - test/functional/apps/dashboard/group4/config.ts - test/functional/apps/dashboard/group5/config.ts + - test/functional/apps/dashboard/group6/config.ts - test/functional/apps/discover/config.ts - test/functional/apps/getting_started/config.ts - test/functional/apps/home/config.ts diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index cda44a96fe4ddf..27bec68ac9014d 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -25,6 +25,7 @@ layout: landing { pageId: "kibDevDocsOpsOptimizer" }, { pageId: "kibDevDocsOpsBabelPreset" }, { pageId: "kibDevDocsOpsTypeSummarizer" }, + { pageId: "kibDevDocsOpsBabelPluginSyntheticPackages"}, ]} /> @@ -45,5 +46,6 @@ layout: landing { pageId: "kibDevDocsOpsExpect" }, { pageId: "kibDevDocsOpsAmbientStorybookTypes" }, { pageId: "kibDevDocsOpsAmbientUiTypes"}, + { pageId: "kibDevDocsOpsTestSubjSelector"}, ]} /> \ No newline at end of file diff --git a/docs/api/data-views/update-fields.asciidoc b/docs/api/data-views/update-fields.asciidoc index 3ec4b7c84694ac..c43daff187528e 100644 --- a/docs/api/data-views/update-fields.asciidoc +++ b/docs/api/data-views/update-fields.asciidoc @@ -60,6 +60,53 @@ $ curl -X POST api/data_views/data-view/my-view/fields -------------------------------------------------- // KIBANA +Change a simple field format: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/data_views/data-view/my-view/fields +{ + "fields": { + "foo": { + "format": { + "id": "bytes" + } + } + } +} +-------------------------------------------------- +// KIBANA + +Change a complex field format: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/data_views/data-view/my-view/fields +{ + "fields": { + "foo": { + "format": { + "id": "static_lookup", + "params": { + "lookupEntries": [ + { + "key": "1", + "value": "100" + }, + { + "key": "2", + "value": "200" + } + ], + "unknownKeyValue": "5000" + } + } + } + } +} +-------------------------------------------------- +// KIBANA + Update multiple metadata fields in one request: [source,sh] @@ -80,6 +127,7 @@ $ curl -X POST api/data_views/data-view/my-view/fields // KIBANA Use `null` value to delete metadata: + [source,sh] -------------------------------------------------- $ curl -X POST api/data_views/data-view/my-pattern/fields @@ -93,8 +141,8 @@ $ curl -X POST api/data_views/data-view/my-pattern/fields -------------------------------------------------- // KIBANA - The endpoint returns the updated data view object: + [source,sh] -------------------------------------------------- { diff --git a/docs/management/connectors/pre-configured-connectors.asciidoc b/docs/management/connectors/pre-configured-connectors.asciidoc index 27d1d80ea7305d..7498784ef389e9 100644 --- a/docs/management/connectors/pre-configured-connectors.asciidoc +++ b/docs/management/connectors/pre-configured-connectors.asciidoc @@ -12,8 +12,6 @@ action are predefined, including the connector name and ID. - Appear in all spaces because they are not saved objects. - Cannot be edited or deleted. -NOTE: Preconfigured connectors cannot be used with cases. - [float] [[preconfigured-connector-example]] ==== Preconfigured connectors example diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index cf501518ea5342..837a83f0aae389 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -1,191 +1,43 @@ [[managing-licenses]] == License Management -When you install the default distribution of {kib}, you receive free features -with no expiration date. For the full list of features, refer to -{subscriptions}. +By default, new installations have a Basic license that never expires. +For the full list of features available at the Free and Open Basic subscription level, +refer to {subscriptions}. -If you want to try out the full set of features, you can activate a free 30-day -trial. To view the status of your license, start a trial, or install a new -license, open the main menu, then click *Stack Management > License Management*. - -NOTE: You can start a trial only if your cluster has not already activated a -trial license for the current major product version. For example, if you have -already activated a trial for 6.0, you cannot start a new trial until -7.0. You can, however, request an extended trial at {extendtrial}. - -When you activate a new license level, new features appear in *Stack Management*. - -[role="screenshot"] -image::images/management-license.png[] +To explore all of the available solutions and features, start a 30-day free trial. +You can activate a trial subscription once per major product version. +If you need more than 30 days to complete your evaluation, +request an extended trial at {extendtrial}. -At the end of the trial period, some features operate in a -<>. You can revert to Basic, extend the trial, -or purchase a subscription. - -TIP: If {security-features} are enabled, unless you have a trial license, -you must configure Transport Layer Security (TLS) in {es}. -See {ref}/encrypting-communications.html[Encrypting communications]. -{kib} and the {ref}/start-basic.html[start basic API] provide a list of all of -the features that will no longer be supported if you revert to a basic license. +To view the status of your license, start a trial, or install a new +license, open the main menu, then click *Stack Management > License Management*. -[float] +[discrete] === Required permissions The `manage` cluster privilege is required to access *License Management*. To add the privilege, open the main menu, then click *Stack Management > Roles*. -[discrete] -[[update-license]] -=== Update your license - -You can update your license at runtime without shutting down your {es} nodes. -License updates take effect immediately. The license is provided as a _JSON_ -file that you install in {kib} or by using the -{ref}/update-license.html[update license API]. - -TIP: If you are using a basic or trial license, {security-features} are disabled -by default. In all other licenses, {security-features} are enabled by default; -you must secure the {stack} or disable the {security-features}. - [discrete] [[license-expiration]] === License expiration -Your license is time based and expires at a future date. If you're using -{monitor-features} and your license will expire within 30 days, a license -expiration warning is displayed prominently. Warnings are also displayed on -startup and written to the {es} log starting 30 days from the expiration date. -These error messages tell you when the license expires and what features will be -disabled if you do not update the license. - -IMPORTANT: You should update your license as soon as possible. You are -essentially flying blind when running with an expired license. Access to the -cluster health and stats APIs is critical for monitoring and managing an {es} -cluster. - -[discrete] -[[expiration-beats]] -==== Beats - -* Beats will continue to poll centrally-managed configuration. - -[discrete] -[[expiration-elasticsearch]] -==== {es} - -// Upgrade API is disabled -* The deprecation API is disabled. -* SQL support is disabled. -* Aggregations provided by the analytics plugin are no longer usable. -* All searchable snapshots indices are unassigned and cannot be searched. - -[discrete] -[[expiration-watcher]] -==== {stack} {alert-features} - -* The PUT and GET watch APIs are disabled. The DELETE watch API continues to work. -* Watches execute and write to the history. -* The actions of the watches do not execute. - -[discrete] -[[expiration-graph]] -==== {stack} {graph-features} - -* Graph explore APIs are disabled. - -[discrete] -[[expiration-ml]] -==== {stack} {ml-features} +Licenses are valid for a specific time period. +30 days before the license expiration date, {es} starts logging expiration warnings. +If monitoring is enabled, expiration warnings are displayed prominently in {kib}. -* APIs to create {anomaly-jobs}, open jobs, send data to jobs, create {dfeeds}, -and start {dfeeds} are disabled. -* All started {dfeeds} are stopped. -* All open {anomaly-jobs} are closed. -* APIs to create and start {dfanalytics-jobs} are disabled. -* Existing {anomaly-job} and {dfanalytics-job} results continue to be available -by using {kib} or APIs. +If your license expires, your subscription level reverts to Basic and +you will no longer be able to use https://www.elastic.co/subscriptions[Platinum or Enterprise features]. [discrete] -[[expiration-monitoring]] -==== {stack} {monitor-features} - -* The agent stops collecting cluster and indices metrics. -* The agent stops automatically cleaning indices older than -`xpack.monitoring.history.duration`. - -[discrete] -[[expiration-security]] -==== {stack} {security-features} - -* Cluster health, cluster stats, and indices stats operations are blocked. -* All data operations (read and write) continue to work. - -Once the license expires, calls to the cluster health, cluster stats, and index -stats APIs fail with a `security_exception` and return a 403 HTTP status code. - -[source,sh] ------------------------------------------------------ -{ - "error": { - "root_cause": [ - { - "type": "security_exception", - "reason": "current license is non-compliant for [security]", - "license.expired.feature": "security" - } - ], - "type": "security_exception", - "reason": "current license is non-compliant for [security]", - "license.expired.feature": "security" - }, - "status": 403 -} ------------------------------------------------------ - -This message enables automatic monitoring systems to easily detect the license -failure without immediately impacting other users. - -[discrete] -[[expiration-logstash]] -==== {ls} pipeline management - -* Cannot create new pipelines or edit or delete existing pipelines from the UI. -* Cannot list or view existing pipelines from the UI. -* Cannot run Logstash instances which are registered to listen to existing pipelines. -//TBD: * Logstash will continue to poll centrally-managed pipelines - -[discrete] -[[expiration-kibana]] -==== {kib} - -* Users can still log into {kib}. -* {kib} works for data exploration and visualization, but some features -are disabled. -* The license management UI is available to easily upgrade your license. See -<> and <>. - -[discrete] -[[expiration-reporting]] -==== {kib} {report-features} - -* Reporting is no longer available in {kib}. -* Report generation URLs stop working. -* Existing reports are no longer accessible. - -[discrete] -[[expiration-rollups]] -==== {rollups-cap} - -* {rollup-jobs-cap} cannot be created or started. -* Existing {rollup-jobs} can be stopped and deleted. -* The get rollup caps and rollup search APIs continue to function. +[[update-license]] +=== Update your license -[discrete] -[[expiration-transforms]] -==== {transforms-cap} +Licenses are provided as a _JSON_ file and have an effective date and an expiration date. +You cannot install a new license before its effective date. +License updates take effect immediately and do not require restarting {es}. -* {transforms-cap} cannot be created, previewed, started, or updated. -* Existing {transforms} can be stopped and deleted. -* Existing {transform} results continue to be available. +You can update your license from *Stack Management > License Management* or through the +{ref}/update-license.html[update license API]. diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 4704430ba94b68..d182492c3da14f 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -181,7 +181,8 @@ "items": [ { "id": "kibDevDocsOpsOptimizer" }, { "id": "kibDevDocsOpsBabelPreset" }, - { "id": "kibDevDocsOpsTypeSummarizer" } + { "id": "kibDevDocsOpsTypeSummarizer" }, + { "id": "kibDevDocsOpsBabelPluginSyntheticPackages"} ] }, { @@ -200,7 +201,8 @@ { "id": "kibDevDocsOpsJestSerializers" }, { "id": "kibDevDocsOpsExpect" }, { "id": "kibDevDocsOpsAmbientStorybookTypes" }, - { "id": "kibDevDocsOpsAmbientUiTypes" } + { "id": "kibDevDocsOpsAmbientUiTypes" }, + { "id": "kibDevDocsOpsTestSubjSelector"} ] } ] diff --git a/package.json b/package.json index 84f9be547e7a19..e5fffb5b3a3948 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "cover:report": "nyc report --temp-dir target/kibana-coverage/functional --report-dir target/coverage/report --reporter=lcov && open ./target/coverage/report/lcov-report/index.html", "debug": "node --nolazy --inspect scripts/kibana --dev", "debug-break": "node --nolazy --inspect-brk scripts/kibana --dev", + "dev-docs": "scripts/dev_docs.sh", "docs:acceptApiChanges": "node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", "es": "node scripts/es", "preinstall": "node ./preinstall_check", @@ -109,7 +110,7 @@ "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.2", "@elastic/ems-client": "8.3.2", - "@elastic/eui": "55.1.2", + "@elastic/eui": "55.1.3", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", @@ -184,6 +185,7 @@ "@kbn/shared-ux-components": "link:bazel-bin/packages/kbn-shared-ux-components", "@kbn/shared-ux-link-redirect-app": "link:bazel-bin/packages/shared-ux/link/redirect_app", "@kbn/shared-ux-page-analytics-no-data": "link:bazel-bin/packages/shared-ux/page/analytics_no_data", + "@kbn/shared-ux-prompt-no-data-views": "link:bazel-bin/packages/shared-ux/prompt/no_data_views", "@kbn/shared-ux-services": "link:bazel-bin/packages/kbn-shared-ux-services", "@kbn/shared-ux-storybook": "link:bazel-bin/packages/kbn-shared-ux-storybook", "@kbn/shared-ux-utility": "link:bazel-bin/packages/kbn-shared-ux-utility", @@ -229,7 +231,6 @@ "base64-js": "^1.3.1", "bitmap-sdf": "^1.0.3", "brace": "0.11.1", - "broadcast-channel": "4.10.0", "canvg": "^3.0.9", "chalk": "^4.1.0", "cheerio": "^1.0.0-rc.10", @@ -683,6 +684,7 @@ "@types/kbn__shared-ux-components": "link:bazel-bin/packages/kbn-shared-ux-components/npm_module_types", "@types/kbn__shared-ux-link-redirect-app": "link:bazel-bin/packages/shared-ux/link/redirect_app/npm_module_types", "@types/kbn__shared-ux-page-analytics-no-data": "link:bazel-bin/packages/shared-ux/page/analytics_no_data/npm_module_types", + "@types/kbn__shared-ux-prompt-no-data-views": "link:bazel-bin/packages/shared-ux/prompt/no_data_views/npm_module_types", "@types/kbn__shared-ux-services": "link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types", "@types/kbn__shared-ux-storybook": "link:bazel-bin/packages/kbn-shared-ux-storybook/npm_module_types", "@types/kbn__shared-ux-utility": "link:bazel-bin/packages/kbn-shared-ux-utility/npm_module_types", @@ -756,7 +758,7 @@ "@types/redux-logger": "^3.0.8", "@types/resolve": "^1.20.1", "@types/seedrandom": ">=2.0.0 <4.0.0", - "@types/selenium-webdriver": "^4.0.19", + "@types/selenium-webdriver": "^4.1.0", "@types/semver": "^7", "@types/set-value": "^2.0.0", "@types/sinon": "^7.0.13", @@ -811,7 +813,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^100.0.0", + "chromedriver": "^101.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", @@ -932,7 +934,7 @@ "resolve": "^1.22.0", "rxjs-marbles": "^5.0.6", "sass-loader": "^10.2.0", - "selenium-webdriver": "^4.1.1", + "selenium-webdriver": "^4.1.2", "shelljs": "^0.8.4", "simple-git": "1.116.0", "sinon": "^7.4.2", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 234a69cb4bdf70..51db32d5d89f7e 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -116,6 +116,7 @@ filegroup( "//packages/shared-ux/button/exit_full_screen:build", "//packages/shared-ux/link/redirect_app:build", "//packages/shared-ux/page/analytics_no_data:build", + "//packages/shared-ux/prompt/no_data_views:build", ], ) @@ -215,6 +216,7 @@ filegroup( "//packages/shared-ux/button/exit_full_screen:build_types", "//packages/shared-ux/link/redirect_app:build_types", "//packages/shared-ux/page/analytics_no_data:build_types", + "//packages/shared-ux/prompt/no_data_views:build_types", ], ) diff --git a/packages/kbn-babel-plugin-synthetic-packages/README.mdx b/packages/kbn-babel-plugin-synthetic-packages/README.mdx new file mode 100644 index 00000000000000..6f11e9cf2d6b9a --- /dev/null +++ b/packages/kbn-babel-plugin-synthetic-packages/README.mdx @@ -0,0 +1,13 @@ +--- +id: kibDevDocsOpsBabelPluginSyntheticPackages +slug: /kibana-dev-docs/ops/babel-plugin-synthetic-packages +title: "@kbn/babel-plugin-synthetic-packages" +description: A babel plugin that transforms our @kbn/{NAME} imports into paths +date: 2022-05-19 +tags: ['kibana', 'dev', 'contributor', 'operations', 'babel', 'plugin', 'synthetic', 'packages'] +--- + +When developing inside the Kibana repository importing a package from any other package is just easy as importing `@kbn/{package-name}`. +However not every package is a node_module yet and while that is something we are working on to accomplish we need a way to dealing with it for +now. Using this babel plugin is our transitory solution. It allows us to import from module ids and then transform it automatically back into +paths on the transpiled code without friction for our engineering teams. \ No newline at end of file diff --git a/packages/kbn-config/BUILD.bazel b/packages/kbn-config/BUILD.bazel index 3567c549a77c41..e735e2cb346eb3 100644 --- a/packages/kbn-config/BUILD.bazel +++ b/packages/kbn-config/BUILD.bazel @@ -38,6 +38,7 @@ RUNTIME_DEPS = [ "//packages/kbn-utility-types", "//packages/kbn-i18n", "//packages/kbn-plugin-discovery", + "//packages/kbn-doc-links", "@npm//js-yaml", "@npm//load-json-file", "@npm//lodash", @@ -54,6 +55,7 @@ TYPES_DEPS = [ "//packages/kbn-utility-types:npm_module_types", "//packages/kbn-i18n:npm_module_types", "//packages/kbn-plugin-discovery:npm_module_types", + "//packages/kbn-doc-links:npm_module_types", "@npm//load-json-file", "@npm//rxjs", "@npm//@types/jest", diff --git a/packages/kbn-config/src/config_service.test.mocks.ts b/packages/kbn-config/src/config_service.test.mocks.ts index 39aa551ae85f95..40379e69a3cb2b 100644 --- a/packages/kbn-config/src/config_service.test.mocks.ts +++ b/packages/kbn-config/src/config_service.test.mocks.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { DocLinks } from '@kbn/doc-links'; + export const mockPackage = new Proxy({ raw: {} as any }, { get: (obj, prop) => obj.raw[prop] }); import type { applyDeprecations } from './deprecation/apply_deprecations'; @@ -26,3 +28,12 @@ export const mockApplyDeprecations = jest.fn< jest.mock('./deprecation/apply_deprecations', () => ({ applyDeprecations: mockApplyDeprecations, })); + +export const docLinksMock = { + settings: 'settings', +} as DocLinks; +export const getDocLinksMock = jest.fn().mockReturnValue(docLinksMock); + +jest.doMock('@kbn/doc-links', () => ({ + getDocLinks: getDocLinksMock, +})); diff --git a/packages/kbn-config/src/config_service.test.ts b/packages/kbn-config/src/config_service.test.ts index 51e67956637eed..b427af4e50229d 100644 --- a/packages/kbn-config/src/config_service.test.ts +++ b/packages/kbn-config/src/config_service.test.ts @@ -9,7 +9,12 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { first, take } from 'rxjs/operators'; -import { mockApplyDeprecations, mockedChangedPaths } from './config_service.test.mocks'; +import { + mockApplyDeprecations, + mockedChangedPaths, + docLinksMock, + getDocLinksMock, +} from './config_service.test.mocks'; import { rawConfigServiceMock } from './raw/raw_config_service.mock'; import { schema } from '@kbn/config-schema'; @@ -39,6 +44,7 @@ const getRawConfigProvider = (rawConfig: Record) => beforeEach(() => { logger = loggerMock.create(); mockApplyDeprecations.mockClear(); + getDocLinksMock.mockClear(); }); test('returns config at path as observable', async () => { @@ -469,6 +475,7 @@ test('calls `applyDeprecations` with the correct parameters', async () => { const context: ConfigDeprecationContext = { branch: defaultEnv.packageInfo.branch, version: defaultEnv.packageInfo.version, + docLinks: docLinksMock, }; const deprecationA = jest.fn(); @@ -479,6 +486,8 @@ test('calls `applyDeprecations` with the correct parameters', async () => { await configService.validate(); + expect(getDocLinksMock).toHaveBeenCalledTimes(1); + expect(mockApplyDeprecations).toHaveBeenCalledTimes(1); expect(mockApplyDeprecations).toHaveBeenCalledWith( cfg, diff --git a/packages/kbn-config/src/config_service.ts b/packages/kbn-config/src/config_service.ts index bb7bb54e75ce5b..0da30aad0e2327 100644 --- a/packages/kbn-config/src/config_service.ts +++ b/packages/kbn-config/src/config_service.ts @@ -12,6 +12,7 @@ import { isEqual } from 'lodash'; import { BehaviorSubject, combineLatest, firstValueFrom, Observable } from 'rxjs'; import { distinctUntilChanged, first, map, shareReplay, tap } from 'rxjs/operators'; import { Logger, LoggerFactory } from '@kbn/logging'; +import { getDocLinks, DocLinks } from '@kbn/doc-links'; import { Config, ConfigPath, Env } from '.'; import { hasConfigPathIntersection } from './config'; @@ -42,6 +43,7 @@ export interface ConfigValidateParameters { export class ConfigService { private readonly log: Logger; private readonly deprecationLog: Logger; + private readonly docLinks: DocLinks; private validated = false; private readonly config$: Observable; @@ -67,6 +69,7 @@ export class ConfigService { ) { this.log = logger.get('config'); this.deprecationLog = logger.get('config', 'deprecation'); + this.docLinks = getDocLinks({ kibanaBranch: env.packageInfo.branch }); this.config$ = combineLatest([this.rawConfigProvider.getConfig$(), this.deprecations]).pipe( map(([rawConfig, deprecations]) => { @@ -104,7 +107,7 @@ export class ConfigService { ...provider(configDeprecationFactory).map((deprecation) => ({ deprecation, path: flatPath, - context: createDeprecationContext(this.env), + context: this.createDeprecationContext(), })), ]); } @@ -262,6 +265,14 @@ export class ConfigService { handledDeprecatedConfig.push(config); this.handledDeprecatedConfigs.set(domainId, handledDeprecatedConfig); } + + private createDeprecationContext(): ConfigDeprecationContext { + return { + branch: this.env.packageInfo.branch, + version: this.env.packageInfo.version, + docLinks: this.docLinks, + }; + } } const pathToString = (path: ConfigPath) => (Array.isArray(path) ? path.join('.') : path); @@ -272,10 +283,3 @@ const pathToString = (path: ConfigPath) => (Array.isArray(path) ? path.join('.') */ const isPathHandled = (path: string, handledPaths: string[]) => handledPaths.some((handledPath) => hasConfigPathIntersection(path, handledPath)); - -const createDeprecationContext = (env: Env): ConfigDeprecationContext => { - return { - branch: env.packageInfo.branch, - version: env.packageInfo.version, - }; -}; diff --git a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts index 5acf725ba93a69..73e7b2b422017c 100644 --- a/packages/kbn-config/src/deprecation/apply_deprecations.test.ts +++ b/packages/kbn-config/src/deprecation/apply_deprecations.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { DocLinks } from '@kbn/doc-links'; import { applyDeprecations } from './apply_deprecations'; import { ConfigDeprecation, ConfigDeprecationContext, ConfigDeprecationWithContext } from './types'; import { configDeprecationFactory as deprecations } from './deprecation_factory'; @@ -14,6 +15,7 @@ describe('applyDeprecations', () => { const context: ConfigDeprecationContext = { version: '7.16.2', branch: '7.16', + docLinks: {} as DocLinks, }; const wrapHandler = ( diff --git a/packages/kbn-config/src/deprecation/deprecations.mock.ts b/packages/kbn-config/src/deprecation/deprecations.mock.ts index 80b65c84b4879e..06b467290b47ea 100644 --- a/packages/kbn-config/src/deprecation/deprecations.mock.ts +++ b/packages/kbn-config/src/deprecation/deprecations.mock.ts @@ -6,12 +6,14 @@ * Side Public License, v 1. */ +import type { DocLinks } from '@kbn/doc-links'; import type { ConfigDeprecationContext } from './types'; const createMockedContext = (): ConfigDeprecationContext => { return { branch: 'master', version: '8.0.0', + docLinks: {} as DocLinks, }; }; diff --git a/packages/kbn-config/src/deprecation/types.ts b/packages/kbn-config/src/deprecation/types.ts index 052741c0b4be31..6d656ab97921f4 100644 --- a/packages/kbn-config/src/deprecation/types.ts +++ b/packages/kbn-config/src/deprecation/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import type { RecursiveReadonly } from '@kbn/utility-types'; +import type { DocLinks } from '@kbn/doc-links'; /** * Config deprecation hook used when invoking a {@link ConfigDeprecation} @@ -77,6 +78,8 @@ export interface ConfigDeprecationContext { version: string; /** The current Kibana branch, e.g `7.x`, `7.16`, `master` */ branch: string; + /** Allow direct access to the doc links from the deprecation handler */ + docLinks: DocLinks; } /** diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 55909e360b0e58..53f69411c43dd6 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -125,7 +125,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { apiKeys: `${WORKPLACE_SEARCH_DOCS}workplace-search-api-authentication.html`, box: `${WORKPLACE_SEARCH_DOCS}workplace-search-box-connector.html`, confluenceCloud: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-cloud-connector.html`, + confluenceCloudConnectorPackage: `${WORKPLACE_SEARCH_DOCS}confluence-cloud.html`, confluenceServer: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-server-connector.html`, + customConnectorPackage: `${WORKPLACE_SEARCH_DOCS}custom-connector-package.html`, customSources: `${WORKPLACE_SEARCH_DOCS}workplace-search-custom-api-sources.html`, customSourcePermissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-custom-api-sources.html#custom-api-source-document-level-access-control`, documentPermissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-sources-document-permissions.html`, @@ -139,7 +141,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { indexingSchedule: `${WORKPLACE_SEARCH_DOCS}workplace-search-customizing-indexing-rules.html#_indexing_schedule`, jiraCloud: `${WORKPLACE_SEARCH_DOCS}workplace-search-jira-cloud-connector.html`, jiraServer: `${WORKPLACE_SEARCH_DOCS}workplace-search-jira-server-connector.html`, + networkDrive: `${WORKPLACE_SEARCH_DOCS}network-drives.html`, oneDrive: `${WORKPLACE_SEARCH_DOCS}workplace-search-onedrive-connector.html`, + outlook: `${WORKPLACE_SEARCH_DOCS}microsoft-outlook.html`, permissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-permissions.html#organizational-sources-private-sources`, salesforce: `${WORKPLACE_SEARCH_DOCS}workplace-search-salesforce-connector.html`, security: `${WORKPLACE_SEARCH_DOCS}workplace-search-security.html`, @@ -148,7 +152,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { sharePointServer: `${WORKPLACE_SEARCH_DOCS}sharepoint-server.html`, slack: `${WORKPLACE_SEARCH_DOCS}workplace-search-slack-connector.html`, synch: `${WORKPLACE_SEARCH_DOCS}workplace-search-customizing-indexing-rules.html`, + teams: `${WORKPLACE_SEARCH_DOCS}microsoft-teams.html`, zendesk: `${WORKPLACE_SEARCH_DOCS}workplace-search-zendesk-connector.html`, + zoom: `${WORKPLACE_SEARCH_DOCS}zoom.html`, }, metricbeat: { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index c492509e80511a..6dc3ad0f5fdda8 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -111,7 +111,9 @@ export interface DocLinks { readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; + readonly confluenceCloudConnectorPackage: string; readonly confluenceServer: string; + readonly customConnectorPackage: string; readonly customSources: string; readonly customSourcePermissions: string; readonly documentPermissions: string; @@ -125,7 +127,9 @@ export interface DocLinks { readonly indexingSchedule: string; readonly jiraCloud: string; readonly jiraServer: string; + readonly networkDrive: string; readonly oneDrive: string; + readonly outlook: string; readonly permissions: string; readonly salesforce: string; readonly security: string; @@ -134,7 +138,9 @@ export interface DocLinks { readonly sharePointServer: string; readonly slack: string; readonly synch: string; + readonly teams: string; readonly zendesk: string; + readonly zoom: string; }; readonly heartbeat: { readonly base: string; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 504ba4906ffd5c..97e9f23784f60b 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -27,7 +27,7 @@ pageLoadAssetSize: indexLifecycleManagement: 107090 indexManagement: 140608 infra: 184320 - fleet: 250000 + fleet: 95000 ingestPipelines: 58003 inputControlVis: 172675 inspector: 148711 @@ -58,7 +58,7 @@ pageLoadAssetSize: telemetry: 51957 telemetryManagementSection: 38586 transform: 41007 - triggersActionsUi: 107800 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728 + triggersActionsUi: 119000 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728 upgradeAssistant: 81241 urlForwarding: 32579 usageCollection: 39762 @@ -128,5 +128,5 @@ pageLoadAssetSize: eventAnnotation: 19334 screenshotting: 22870 synthetics: 40958 - expressionXY: 31000 + expressionXY: 33000 kibanaUsageCollection: 16463 diff --git a/packages/kbn-shared-ux-components/BUILD.bazel b/packages/kbn-shared-ux-components/BUILD.bazel index b1420f53760419..1a4a7100ded72d 100644 --- a/packages/kbn-shared-ux-components/BUILD.bazel +++ b/packages/kbn-shared-ux-components/BUILD.bazel @@ -44,6 +44,7 @@ RUNTIME_DEPS = [ "//packages/kbn-i18n", "//packages/shared-ux/avatar/solution", "//packages/shared-ux/link/redirect_app", + "//packages/shared-ux/prompt/no_data_views", "//packages/kbn-shared-ux-services", "//packages/kbn-shared-ux-storybook", "//packages/kbn-shared-ux-utility", @@ -72,6 +73,7 @@ TYPES_DEPS = [ "//packages/kbn-i18n:npm_module_types", "//packages/shared-ux/avatar/solution:npm_module_types", "//packages/shared-ux/link/redirect_app:npm_module_types", + "//packages/shared-ux/prompt/no_data_views:npm_module_types", "//packages/kbn-shared-ux-services:npm_module_types", "//packages/kbn-shared-ux-storybook:npm_module_types", "//packages/kbn-shared-ux-utility:npm_module_types", diff --git a/packages/kbn-shared-ux-components/src/empty_state/index.ts b/packages/kbn-shared-ux-components/src/empty_state/index.ts index 68defa52693441..9883d595633a7f 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/index.ts +++ b/packages/kbn-shared-ux-components/src/empty_state/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export { NoDataViews, NoDataViewsComponent } from './no_data_views'; export { KibanaNoDataPage } from './kibana_no_data_page'; diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx index 4f565e55ef52ce..3b117f54369a09 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx @@ -12,10 +12,10 @@ import { act } from 'react-dom/test-utils'; import { EuiLoadingElastic } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { SharedUxServicesProvider, mockServicesFactory } from '@kbn/shared-ux-services'; +import { NoDataViewsPrompt } from '@kbn/shared-ux-prompt-no-data-views'; import { KibanaNoDataPage } from './kibana_no_data_page'; import { NoDataConfigPage } from '../page_template'; -import { NoDataViews } from './no_data_views'; describe('Kibana No Data Page', () => { const noDataConfig = { @@ -52,7 +52,7 @@ describe('Kibana No Data Page', () => { component.update(); expect(component.find(NoDataConfigPage).length).toBe(1); - expect(component.find(NoDataViews).length).toBe(0); + expect(component.find(NoDataViewsPrompt).length).toBe(0); }); test('renders NoDataViews', async () => { @@ -66,7 +66,7 @@ describe('Kibana No Data Page', () => { await act(() => new Promise(setImmediate)); component.update(); - expect(component.find(NoDataViews).length).toBe(1); + expect(component.find(NoDataViewsPrompt).length).toBe(1); expect(component.find(NoDataConfigPage).length).toBe(0); }); @@ -90,7 +90,7 @@ describe('Kibana No Data Page', () => { component.update(); expect(component.find(EuiLoadingElastic).length).toBe(1); - expect(component.find(NoDataViews).length).toBe(0); + expect(component.find(NoDataViewsPrompt).length).toBe(0); expect(component.find(NoDataConfigPage).length).toBe(0); }); }); diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx index 89ba915c07cfda..5d0f84e0bd41b3 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx @@ -6,10 +6,14 @@ * Side Public License, v 1. */ import React, { useEffect, useState } from 'react'; +import { useData, useDocLinks, useEditors, usePermissions } from '@kbn/shared-ux-services'; +import { + NoDataViewsPrompt, + NoDataViewsPromptProvider, + NoDataViewsPromptServices, +} from '@kbn/shared-ux-prompt-no-data-views'; import { EuiLoadingElastic } from '@elastic/eui'; -import { useData } from '@kbn/shared-ux-services'; import { NoDataConfigPage, NoDataPageProps } from '../page_template'; -import { NoDataViews } from './no_data_views'; export interface Props { onDataViewCreated: (dataView: unknown) => void; @@ -17,6 +21,11 @@ export interface Props { } export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => { + // These hooks are temporary, until this component is moved to a package. + const { canCreateNewDataView } = usePermissions(); + const { dataViewsDocLink } = useDocLinks(); + const { openDataViewEditor } = useEditors(); + const { hasESData, hasUserDataView } = useData(); const [isLoading, setIsLoading] = useState(true); const [dataExists, setDataExists] = useState(false); @@ -43,8 +52,26 @@ export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => return ; } + /* + TODO: clintandrewhall - the use and population of `NoDataViewPromptProvider` here is temporary, + until `KibanaNoDataPage` is moved to a package of its own. + + Once `KibanaNoDataPage` is moved to a package, `NoDataViewsPromptProvider` will be *combined* + with `KibanaNoDataPageProvider`, creating a single Provider that manages contextual dependencies + throughout the React tree from the top-level of composition and consumption. + */ if (!hasUserDataViews) { - return ; + const services: NoDataViewsPromptServices = { + canCreateNewDataView, + dataViewsDocLink, + openDataViewEditor, + }; + + return ( + + + + ); } return null; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx deleted file mode 100644 index bee7c87d2841bb..00000000000000 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.stories.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import React from 'react'; -import { action } from '@storybook/addon-actions'; - -import { servicesFactory } from '@kbn/shared-ux-storybook'; - -import { NoDataViews as NoDataViewsComponent, Props } from './no_data_views.component'; -import { NoDataViews } from './no_data_views'; - -import mdx from './no_data_views.mdx'; - -const services = servicesFactory({}); - -export default { - title: 'No Data/No Data Views', - description: 'A component to display when there are no user-created data views available.', - parameters: { - docs: { - page: mdx, - }, - }, -}; - -export const ConnectedComponent = () => { - return ; -}; - -type Params = Pick; - -export const PureComponent = (params: Params) => { - return ; -}; - -PureComponent.argTypes = { - canCreateNewDataView: { - control: 'boolean', - defaultValue: true, - }, - dataViewsDocLink: { - options: [services.docLinks.dataViewsDocLink, undefined], - control: { type: 'radio' }, - }, -}; diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts index 77586e8592b6a8..fb4676e9f4e55e 100644 --- a/packages/kbn-shared-ux-components/src/index.ts +++ b/packages/kbn-shared-ux-components/src/index.ts @@ -90,44 +90,3 @@ export const KibanaPageTemplateSolutionNavLazy = React.lazy(() => default: KibanaPageTemplateSolutionNav, })) ); - -/** - * A `KibanaPageTemplateSolutionNav` component that is wrapped by the `withSuspense` HOC. This component can - * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavLazy` component lazily with - * a predefined fallback and error boundary. - */ -export const KibanaPageTemplateSolutionNav = withSuspense(KibanaPageTemplateSolutionNavLazy); - -/** - * The Lazily-loaded `NoDataViews` component. Consumers should use `React.Suspennse` or the - * `withSuspense` HOC to load this component. - */ -export const NoDataViewsLazy = React.lazy(() => - import('./empty_state/no_data_views').then(({ NoDataViews }) => ({ - default: NoDataViews, - })) -); - -/** - * A `NoDataViews` component that is wrapped by the `withSuspense` HOC. This component can - * be used directly by consumers and will load the `LazyNoDataViews` component lazily with - * a predefined fallback and error boundary. - */ -export const NoDataViews = withSuspense(NoDataViewsLazy); - -/** - * A pure `NoDataViews` component, with no services hooks. Consumers should use `React.Suspennse` or the - * `withSuspense` HOC to load this component. - */ -export const NoDataViewsComponentLazy = React.lazy(() => - import('./empty_state/no_data_views').then(({ NoDataViewsComponent }) => ({ - default: NoDataViewsComponent, - })) -); - -/** - * A pure `NoDataViews` component, with no services hooks. The component is wrapped by the `withSuspense` HOC. - * This component can be used directly by consumers and will load the `LazyNoDataViewsComponent` lazily with - * a predefined fallback and error boundary. - */ -export const NoDataViewsComponent = withSuspense(NoDataViewsComponentLazy); diff --git a/packages/kbn-test-jest-helpers/src/index.ts b/packages/kbn-test-jest-helpers/src/index.ts index 809d4380df10a6..5e794abdbbb781 100644 --- a/packages/kbn-test-jest-helpers/src/index.ts +++ b/packages/kbn-test-jest-helpers/src/index.ts @@ -18,6 +18,8 @@ export * from './redux_helpers'; export * from './router_helpers'; +export * from './stub_broadcast_channel'; + export * from './stub_browser_storage'; export * from './stub_web_worker'; diff --git a/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts b/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.ts new file mode 100644 index 00000000000000..ecf34aa7bb68e6 --- /dev/null +++ b/packages/kbn-test-jest-helpers/src/stub_broadcast_channel.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const channelCache: BroadcastChannel[] = []; + +class StubBroadcastChannel implements BroadcastChannel { + constructor(public readonly name: string) { + channelCache.push(this); + } + + onmessage = jest.fn(); + onmessageerror = jest.fn(); + close = jest.fn(); + postMessage = jest.fn().mockImplementation((data: any) => { + channelCache.forEach((channel) => { + if (channel === this) return; // don't postMessage to ourselves + if (channel.onmessage) { + channel.onmessage(new MessageEvent(this.name, { data })); + } + }); + }); + + addEventListener( + type: K, + listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener(type: any, listener: any, options?: any): void { + throw new Error('Method not implemented.'); + } + removeEventListener( + type: K, + listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => any, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void; + removeEventListener(type: any, listener: any, options?: any): void { + throw new Error('Method not implemented.'); + } + dispatchEvent(event: Event): boolean { + throw new Error('Method not implemented.'); + } +} + +/** + * Returns all BroadcastChannel instances. + * @returns BroadcastChannel[] + */ +function getBroadcastChannelInstances() { + return [...channelCache]; +} + +/** + * Removes all BroadcastChannel instances. + */ +function clearBroadcastChannelInstances() { + channelCache.splice(0, channelCache.length); +} + +/** + * Stubs the global window.BroadcastChannel for use in jest tests. + */ +function stubBroadcastChannel() { + if (!window.BroadcastChannel) { + window.BroadcastChannel = StubBroadcastChannel; + } +} + +export { stubBroadcastChannel, getBroadcastChannelInstances, clearBroadcastChannelInstances }; diff --git a/packages/kbn-test-subj-selector/BUILD.bazel b/packages/kbn-test-subj-selector/BUILD.bazel index f494b558ad5a66..cc3334650a5d9b 100644 --- a/packages/kbn-test-subj-selector/BUILD.bazel +++ b/packages/kbn-test-subj-selector/BUILD.bazel @@ -18,7 +18,6 @@ filegroup( NPM_MODULE_EXTRA_FILES = [ "package.json", - "README.md", ] RUNTIME_DEPS = [] diff --git a/packages/kbn-test-subj-selector/README.md b/packages/kbn-test-subj-selector/README.md deleted file mode 100755 index 463d6c808e298d..00000000000000 --- a/packages/kbn-test-subj-selector/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# test-subj-selector - -Convert a string from test subject syntax to css selectors. diff --git a/packages/kbn-test-subj-selector/README.mdx b/packages/kbn-test-subj-selector/README.mdx new file mode 100755 index 00000000000000..c924d159371292 --- /dev/null +++ b/packages/kbn-test-subj-selector/README.mdx @@ -0,0 +1,10 @@ +--- +id: kibDevDocsOpsTestSubjSelector +slug: /kibana-dev-docs/ops/test-subj-selector +title: "@kbn/test-subj-selector" +description: An utility package to quickly get css selectors from strings +date: 2022-05-19 +tags: ['kibana', 'dev', 'contributor', 'operations', 'test', 'subj', 'selector'] +--- + +Converts a string from a test subject syntax into a css selectors composed by `data-test-subj`. diff --git a/packages/shared-ux/prompt/no_data_views/BUILD.bazel b/packages/shared-ux/prompt/no_data_views/BUILD.bazel new file mode 100644 index 00000000000000..91fae6aeddea99 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/BUILD.bazel @@ -0,0 +1,142 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "no_data_views" +PKG_REQUIRE_NAME = "@kbn/shared-ux-prompt-no-data-views" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.mdx", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//@elastic/eui", + "@npm//@emotion/css", + "@npm//@emotion/react", + "@npm//@storybook/addon-actions", + "@npm//enzyme", + "@npm//react", + "//packages/kbn-i18n-react", + "//packages/kbn-i18n", + "//packages/kbn-shared-ux-utility", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@elastic/eui", + "@npm//@emotion/css", + "@npm//@emotion/react", + "@npm//@storybook/addon-actions", + "@npm//@types/enzyme", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react", + "//packages/kbn-ambient-ui-types", + "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-shared-ux-utility:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx b/packages/shared-ux/prompt/no_data_views/README.mdx similarity index 74% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx rename to packages/shared-ux/prompt/no_data_views/README.mdx index ef8812c565a9f3..730470c72f1701 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.mdx +++ b/packages/shared-ux/prompt/no_data_views/README.mdx @@ -1,7 +1,7 @@ -**id:** sharedUX/Components/NoDataViewsPage -**slug:** /shared-ux/components/no-data-views-page -**title:** No Data Views Page -**summary:** A page to be displayed when there is data in Elasticsearch, but no data views +**id:** sharedUX/Components/NoDataViewsPrompt +**slug:** /shared-ux/components/no-data-views +**title:** No Data Views +**summary:** A prompt to be displayed when there is data in Elasticsearch, but no data views **tags:** ['shared-ux', 'component'] **date:** 2022-02-09 diff --git a/packages/shared-ux/prompt/no_data_views/jest.config.js b/packages/shared-ux/prompt/no_data_views/jest.config.js new file mode 100644 index 00000000000000..a89d3ff2220896 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/prompt/no_data_views'], +}; diff --git a/packages/shared-ux/prompt/no_data_views/package.json b/packages/shared-ux/prompt/no_data_views/package.json new file mode 100644 index 00000000000000..79070e12429943 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-prompt-no-data-views", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap b/packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap similarity index 82% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap rename to packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap index e84b997d8df875..0f7160c7b06e8c 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/__snapshots__/documentation_link.test.tsx.snap +++ b/packages/shared-ux/prompt/no_data_views/src/__snapshots__/documentation_link.test.tsx.snap @@ -10,7 +10,7 @@ exports[` is rendered correctly 1`] = ` > @@ -26,7 +26,7 @@ exports[` is rendered correctly 1`] = ` > diff --git a/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx b/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx new file mode 100644 index 00000000000000..8a889a9267dee4 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/data_view_illustration.tsx @@ -0,0 +1,552 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; + +export const DataViewIllustration = () => { + const { euiTheme } = useEuiTheme(); + const { colors } = euiTheme; + + const dataViewIllustrationVerticalStripes = css` + fill: ${colors.fullShade}; + `; + + const dataViewIllustrationDots = css` + fill: ${colors.lightShade}; + `; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.test.tsx b/packages/shared-ux/prompt/no_data_views/src/documentation_link.test.tsx similarity index 100% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/documentation_link.test.tsx diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx b/packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx similarity index 88% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx rename to packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx index 3b3e742ea74ce2..2b40f30acc7796 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/documentation_link.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/documentation_link.tsx @@ -20,7 +20,7 @@ export function DocumentationLink({ href }: Props) {
@@ -29,7 +29,7 @@ export function DocumentationLink({ href }: Props) {
diff --git a/packages/shared-ux/prompt/no_data_views/src/index.tsx b/packages/shared-ux/prompt/no_data_views/src/index.tsx new file mode 100644 index 00000000000000..23c2ed068f2af8 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/index.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { withSuspense } from '@kbn/shared-ux-utility'; + +export { NoDataViewsPromptKibanaProvider, NoDataViewsPromptProvider } from './services'; +export type { NoDataViewsPromptKibanaServices, NoDataViewsPromptServices } from './services'; + +/** + * The Lazily-loaded `NoDataViewsPrompt` component. Consumers should use `React.Suspennse` or the + * `withSuspense` HOC to load this component. + */ +export const NoDataViewsPromptLazy = React.lazy(() => + import('./no_data_views').then(({ NoDataViewsPrompt }) => ({ + default: NoDataViewsPrompt, + })) +); + +/** + * A `NoDataViewsPrompt` component that is wrapped by the `withSuspense` HOC. This component can + * be used directly by consumers and will load the `NoDataViewsPromptLazy` component lazily with + * a predefined fallback and error boundary. + */ +export const NoDataViewsPrompt = withSuspense(NoDataViewsPromptLazy); + +/** + * A pure `NoDataViewsPrompt` component, with no services hooks. Consumers should use `React.Suspennse` or the + * `withSuspense` HOC to load this component. + */ +export const NoDataViewsPromptComponentLazy = React.lazy(() => + import('./no_data_views.component').then(({ NoDataViewsPrompt: Component }) => ({ + default: Component, + })) +); + +/** + * A pure `NoDataViewsPrompt` component, with no services hooks. The component is wrapped by the `withSuspense` HOC. + * This component can be used directly by consumers and will load the `NoDataViewsComponentLazy` lazily with + * a predefined fallback and error boundary. + */ +export const NoDataViewsPromptComponent = withSuspense(NoDataViewsPromptComponentLazy); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx similarity index 79% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx index 87dd68e202bc2d..d0de72797cc2f5 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.test.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -import { NoDataViews } from './no_data_views.component'; +import { NoDataViewsPrompt } from './no_data_views.component'; import { DocumentationLink } from './documentation_link'; -describe('', () => { +describe('', () => { test('is rendered correctly', () => { const component = mountWithIntl( - ', () => { }); test('does not render button if canCreateNewDataViews is false', () => { - const component = mountWithIntl(); + const component = mountWithIntl(); expect(component.find(EuiButton).length).toBe(0); }); test('does not documentation link if linkToDocumentation is not provided', () => { const component = mountWithIntl( - + ); expect(component.find(DocumentationLink).length).toBe(0); @@ -43,7 +43,7 @@ describe('', () => { test('onClickCreate', () => { const onClickCreate = jest.fn(); const component = mountWithIntl( - + ); component.find('button').simulate('click'); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx similarity index 77% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx index 3131b6ab2a73c0..f53a187265703a 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.component.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiEmptyPrompt, EuiEmptyPromptProps } from '@elastic/eui'; -import { DataViewIllustration } from '../assets'; +import { DataViewIllustration } from './data_view_illustration'; import { DocumentationLink } from './documentation_link'; export interface Props { @@ -23,7 +23,7 @@ export interface Props { emptyPromptColor?: EuiEmptyPromptProps['color']; } -const createDataViewText = i18n.translate('sharedUXComponents.noDataViewsPrompt.addDataViewText', { +const createDataViewText = i18n.translate('sharedUXPackages.noDataViewsPrompt.addDataViewText', { defaultMessage: 'Create data view', }); @@ -33,13 +33,13 @@ const MAX_WIDTH = 830; /** * A presentational component that is shown in cases when there are no data views created yet. */ -export const NoDataViews = ({ +export const NoDataViewsPrompt = ({ onClickCreate, canCreateNewDataView, dataViewsDocLink, emptyPromptColor = 'plain', }: Props) => { - const createNewButton = canCreateNewDataView && ( + const actions = canCreateNewDataView && (
) : (

@@ -74,19 +74,22 @@ export const NoDataViews = ({ const body = canCreateNewDataView ? (

) : (

); + const icon = ; + const footer = dataViewsDocLink ? : undefined; + return ( } - title={title} - body={body} - actions={createNewButton} - footer={dataViewsDocLink && } + {...{ actions, icon, title, body, footer }} /> ); }; diff --git a/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx new file mode 100644 index 00000000000000..c9e983c5f01b21 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.stories.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { action } from '@storybook/addon-actions'; + +import { NoDataViewsPrompt as NoDataViewsPromptComponent, Props } from './no_data_views.component'; +import { NoDataViewsPrompt } from './no_data_views'; +import { NoDataViewsPromptProvider, NoDataViewsPromptServices } from './services'; + +import mdx from '../README.mdx'; + +export default { + title: 'No Data/No Data Views', + description: 'A component to display when there are no user-created data views available.', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +type ConnectedParams = Pick; + +const openDataViewEditor: NoDataViewsPromptServices['openDataViewEditor'] = (options) => { + action('openDataViewEditor')(options); + return () => {}; +}; + +export const ConnectedComponent = (params: ConnectedParams) => { + return ( + + + + ); +}; + +ConnectedComponent.argTypes = { + canCreateNewDataView: { + control: 'boolean', + defaultValue: true, + }, + dataViewsDocLink: { + options: ['some/link', undefined], + control: { type: 'radio' }, + }, +}; + +type PureParams = Pick; + +export const PureComponent = (params: PureParams) => { + return ; +}; + +PureComponent.argTypes = { + canCreateNewDataView: { + control: 'boolean', + defaultValue: true, + }, + dataViewsDocLink: { + options: ['some/link', undefined], + control: { type: 'radio' }, + }, +}; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx similarity index 53% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx index bb067544013c8f..041e71d87e2ae3 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.test.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.test.tsx @@ -12,21 +12,23 @@ import { ReactWrapper } from 'enzyme'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { EuiButton } from '@elastic/eui'; -import { - SharedUxServicesProvider, - SharedUxServices, - mockServicesFactory, -} from '@kbn/shared-ux-services'; -import { NoDataViews } from './no_data_views'; - -describe('', () => { - let services: SharedUxServices; +import { NoDataViewsPrompt } from './no_data_views'; +import { NoDataViewsPromptServices, NoDataViewsPromptProvider } from './services'; + +const getServices = (canCreateNewDataView: boolean = true) => ({ + canCreateNewDataView, + openDataViewEditor: jest.fn(), + dataViewsDocLink: 'some/link', +}); + +describe('', () => { + let services: NoDataViewsPromptServices; let mount: (element: JSX.Element) => ReactWrapper; beforeEach(() => { - services = mockServicesFactory(); + services = getServices(); mount = (element: JSX.Element) => - mountWithIntl({element}); + mountWithIntl({element}); }); afterEach(() => { @@ -34,13 +36,13 @@ describe('', () => { }); test('on dataView created', () => { - const component = mount(); + const component = mount(); - expect(services.editors.openDataViewEditor).not.toHaveBeenCalled(); + expect(services.openDataViewEditor).not.toHaveBeenCalled(); component.find(EuiButton).simulate('click'); component.unmount(); - expect(services.editors.openDataViewEditor).toHaveBeenCalled(); + expect(services.openDataViewEditor).toHaveBeenCalled(); }); }); diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx b/packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx similarity index 72% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx rename to packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx index 8d0e6d93275e18..da618674810cef 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx +++ b/packages/shared-ux/prompt/no_data_views/src/no_data_views.tsx @@ -8,20 +8,18 @@ import React, { useCallback, useEffect, useRef } from 'react'; -import { useEditors, usePermissions, useDocLinks } from '@kbn/shared-ux-services'; -import type { SharedUxEditorsService } from '@kbn/shared-ux-services'; - -import { NoDataViews as NoDataViewsComponent } from './no_data_views.component'; +import { NoDataViewsPrompt as NoDataViewsPromptComponent } from './no_data_views.component'; +import { useServices, NoDataViewsPromptServices } from './services'; // TODO: https://github.com/elastic/kibana/issues/127695 export interface Props { onDataViewCreated: (dataView: unknown) => void; } -type CloseDataViewEditorFn = ReturnType; +type CloseDataViewEditorFn = ReturnType; /** - * A service-enabled component that provides Kibana-specific functionality to the `NoDataViews` + * A service-enabled component that provides Kibana-specific functionality to the `NoDataViewsPrompt` * component. * * Use of this component requires both the `EuiTheme` context as well as either a configured Shared UX @@ -29,10 +27,8 @@ type CloseDataViewEditorFn = ReturnType { - const { canCreateNewDataView } = usePermissions(); - const { openDataViewEditor } = useEditors(); - const { dataViewsDocLink } = useDocLinks(); +export const NoDataViewsPrompt = ({ onDataViewCreated }: Props) => { + const { canCreateNewDataView, openDataViewEditor, dataViewsDocLink } = useServices(); const closeDataViewEditor = useRef(); useEffect(() => { @@ -69,5 +65,7 @@ export const NoDataViews = ({ onDataViewCreated }: Props) => { } }, [canCreateNewDataView, openDataViewEditor, setDataViewEditorRef, onDataViewCreated]); - return ; + return ( + + ); }; diff --git a/packages/shared-ux/prompt/no_data_views/src/services.tsx b/packages/shared-ux/prompt/no_data_views/src/services.tsx new file mode 100644 index 00000000000000..58d21d1845b56c --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/src/services.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useContext } from 'react'; + +/** + * TODO: `DataView` is a class exported by `src/plugins/data_views/public`. Since this service + * is contained in this package-- and packages can only depend on other packages and never on + * plugins-- we have to set this to `unknown`. If and when `DataView` is exported from a + * stateless package, we can remove this. + * + * @see: https://github.com/elastic/kibana/issues/127695 + */ +type DataView = unknown; + +/** + * A subset of the `DataViewEditorOptions` interface relevant to our service and components. + * + * @see: src/plugins/data_view_editor/public/types.ts + */ +interface DataViewEditorOptions { + /** Handler to be invoked when the Data View Editor completes a save operation. */ + onSave: (dataView: DataView) => void; + /** If set to false, will skip empty prompt in data view editor. */ + showEmptyPrompt?: boolean; +} + +/** + * Abstract external services for this component. + */ +export interface NoDataViewsPromptServices { + /** True if the user has permission to create a new Data View, false otherwise. */ + canCreateNewDataView: boolean; + /** A method to open the Data View Editor flow. */ + openDataViewEditor: (options: DataViewEditorOptions) => () => void; + /** A link to information about Data Views in Kibana */ + dataViewsDocLink: string; +} + +const NoDataViewsPromptContext = React.createContext(null); + +/** + * Abstract external service Provider. + */ +export const NoDataViewsPromptProvider: FC = ({ + children, + ...services +}) => { + return ( + + {children} + + ); +}; + +/** + * Kibana-specific service types. + */ +export interface NoDataViewsPromptKibanaServices { + coreStart: { + docLinks: { + links: { + indexPatterns: { + introduction: string; + }; + }; + }; + }; + dataViewEditor: { + userPermissions: { + editDataView: () => boolean; + }; + openEditor: (options: DataViewEditorOptions) => () => void; + }; +} + +/** + * Kibana-specific Provider that maps to known dependency types. + */ +export const NoDataViewsPromptKibanaProvider: FC = ({ + children, + ...services +}) => { + return ( + + {children} + + ); +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(NoDataViewsPromptContext); + + if (!context) { + throw new Error( + 'NoDataViewsPromptContext is missing. Ensure your component or React root is wrapped with NoDataViewsPromptProvider.' + ); + } + + return context; +} diff --git a/packages/shared-ux/prompt/no_data_views/tsconfig.json b/packages/shared-ux/prompt/no_data_views/tsconfig.json new file mode 100644 index 00000000000000..45842fa3da4727 --- /dev/null +++ b/packages/shared-ux/prompt/no_data_views/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@emotion/react/types/css-prop", + "@kbn/ambient-ui-types", + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/renovate.json b/renovate.json index 3d24e88d638b06..628eeec7c6e359 100644 --- a/renovate.json +++ b/renovate.json @@ -114,7 +114,6 @@ { "groupName": "platform security modules", "matchPackageNames": [ - "broadcast-channel", "node-forge", "@types/node-forge", "require-in-the-middle", diff --git a/scripts/dev_docs.sh b/scripts/dev_docs.sh new file mode 100755 index 00000000000000..55d8f4d51e8dcf --- /dev/null +++ b/scripts/dev_docs.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -euo pipefail + +KIBANA_DIR=$(cd "$(dirname "$0")"/.. && pwd) +WORKSPACE=$(cd "$KIBANA_DIR/.." && pwd)/kibana-docs +export NVM_DIR="$WORKSPACE/.nvm" + +DOCS_DIR="$WORKSPACE/docs.elastic.dev" + +# These are the other repos with docs currently required to build the docs in this repo and not get errors +# For example, kibana docs link to docs in these repos, and if they aren't built, you'll get errors +DEV_DIR="$WORKSPACE/dev" +TEAM_DIR="$WORKSPACE/kibana-team" + +cd "$KIBANA_DIR" +origin=$(git remote get-url origin || true) +GIT_PREFIX="git@github.com:" +if [[ "$origin" == "https"* ]]; then + GIT_PREFIX="https://github.com/" +fi + +mkdir -p "$WORKSPACE" +cd "$WORKSPACE" + +if [[ ! -d "$NVM_DIR" ]]; then + echo "Installing a separate copy of nvm" + git clone https://github.com/nvm-sh/nvm.git "$NVM_DIR" + cd "$NVM_DIR" + git checkout "$(git describe --abbrev=0 --tags --match "v[0-9]*" "$(git rev-list --tags --max-count=1)")" + cd "$WORKSPACE" +fi +source "$NVM_DIR/nvm.sh" + +if [[ ! -d "$DOCS_DIR" ]]; then + echo "Cloning docs.elastic.dev repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/docs.elastic.dev.git" +else + cd "$DOCS_DIR" + git pull + cd "$WORKSPACE" +fi + +if [[ ! -d "$DEV_DIR" ]]; then + echo "Cloning dev repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/dev.git" +else + cd "$DEV_DIR" + git pull + cd "$WORKSPACE" +fi + +if [[ ! -d "$TEAM_DIR" ]]; then + echo "Cloning kibana-team repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/kibana-team.git" +else + cd "$TEAM_DIR" + git pull + cd "$WORKSPACE" +fi + +# The minimum sources required to build kibana docs +cat << EOF > "$DOCS_DIR/sources-dev.json" +{ + "sources": [ + { + "type": "file", + "location": "$KIBANA_DIR" + }, + { + "type": "file", + "location": "$DEV_DIR" + }, + { + "type": "file", + "location": "$TEAM_DIR" + } + ] +} +EOF + +cd "$DOCS_DIR" +nvm install + +if ! which yarn; then + npm install -g yarn +fi + +yarn + +if [[ ! -d .docsmobile ]]; then + yarn init-docs +fi + +echo "" +echo "The docs.elastic.dev project is located at:" +echo "$DOCS_DIR" +echo "" + +if [[ "${1:-}" ]]; then + yarn "$@" +else + yarn dev +fi diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index f10fb0231352dd..66e2664b2e8b49 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -77,6 +77,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@8.3.2': ['Elastic License 2.0'], - '@elastic/eui@55.1.2': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@55.1.3': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts index 76e524960b1598..1f19428e420bf1 100644 --- a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts @@ -35,7 +35,7 @@ export const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable = id: 'c', name: 'c', meta: { - type: 'string', + type: 'date', field: 'order_date', sourceParams: { type: 'date-histogram', params: { interval: 'auto' } }, params: { id: 'string' }, @@ -128,8 +128,8 @@ export const createArgsWithLayers = ( export function sampleArgs() { const data = createSampleDatatableWithRows([ - { a: 1, b: 2, c: 'I', d: 'Foo' }, - { a: 1, b: 5, c: 'J', d: 'Bar' }, + { a: 1, b: 2, c: 1652034840000, d: 'Foo' }, + { a: 1, b: 5, c: 1652122440000, d: 'Bar' }, ]); return { diff --git a/src/plugins/chart_expressions/expression_xy/common/constants.ts b/src/plugins/chart_expressions/expression_xy/common/constants.ts index 68ac2963c96469..fc2e41700b94fb 100644 --- a/src/plugins/chart_expressions/expression_xy/common/constants.ts +++ b/src/plugins/chart_expressions/expression_xy/common/constants.ts @@ -9,6 +9,7 @@ export const XY_VIS = 'xyVis'; export const LAYERED_XY_VIS = 'layeredXyVis'; export const Y_CONFIG = 'yConfig'; +export const REFERENCE_LINE_Y_CONFIG = 'referenceLineYConfig'; export const EXTENDED_Y_CONFIG = 'extendedYConfig'; export const DATA_LAYER = 'dataLayer'; export const EXTENDED_DATA_LAYER = 'extendedDataLayer'; @@ -19,8 +20,8 @@ export const ANNOTATION_LAYER = 'annotationLayer'; export const EXTENDED_ANNOTATION_LAYER = 'extendedAnnotationLayer'; export const TICK_LABELS_CONFIG = 'tickLabelsConfig'; export const AXIS_EXTENT_CONFIG = 'axisExtentConfig'; +export const REFERENCE_LINE = 'referenceLine'; export const REFERENCE_LINE_LAYER = 'referenceLineLayer'; -export const EXTENDED_REFERENCE_LINE_LAYER = 'extendedReferenceLineLayer'; export const LABELS_ORIENTATION_CONFIG = 'labelsOrientationConfig'; export const AXIS_TITLES_VISIBILITY_CONFIG = 'axisTitlesVisibilityConfig'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap index 68262f8a4f3ded..9abd76c669b8f9 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`extendedDataLayerConfig throws the error if lineWidth is provided to the not line/area chart 1`] = `"\`lineWidth\` can be applied only for line or area charts"`; + exports[`extendedDataLayerConfig throws the error if markSizeAccessor doesn't have the corresponding column in the table 1`] = `"Provided column name or index is invalid: nonsense"`; exports[`extendedDataLayerConfig throws the error if markSizeAccessor is provided to the not line/area chart 1`] = `"\`markSizeAccessor\` can't be used. Dots are applied only for line or area charts"`; + +exports[`extendedDataLayerConfig throws the error if pointsRadius is provided to the not line/area chart 1`] = `"\`pointsRadius\` can be applied only for line or area charts"`; + +exports[`extendedDataLayerConfig throws the error if showPoints is provided to the not line/area chart 1`] = `"\`showPoints\` can be applied only for line or area charts"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap index 05109cc65446b6..e396aace051910 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xyVis it should throw error if addTimeMarker applied for not time chart 1`] = `"Only time charts can have current time marker"`; + exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 1`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 2`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts index 0c9085cce7664d..c7f2da8ec15436 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts @@ -36,7 +36,6 @@ export const commonDataLayerArgs: Omit< xScaleType: { options: [...Object.values(XScaleTypes)], help: strings.getXScaleTypeHelp(), - default: XScaleTypes.ORDINAL, strict: true, }, isHistogram: { @@ -44,6 +43,18 @@ export const commonDataLayerArgs: Omit< default: false, help: strings.getIsHistogramHelp(), }, + lineWidth: { + types: ['number'], + help: strings.getLineWidthHelp(), + }, + showPoints: { + types: ['boolean'], + help: strings.getShowPointsHelp(), + }, + pointsRadius: { + types: ['number'], + help: strings.getPointsRadiusHelp(), + }, yConfig: { types: [Y_CONFIG], help: strings.getYConfigHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts deleted file mode 100644 index d85f5ae2b2f770..00000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { EXTENDED_Y_CONFIG } from '../constants'; -import { strings } from '../i18n'; -import { ReferenceLineLayerFn, ExtendedReferenceLineLayerFn } from '../types'; - -type CommonReferenceLineLayerFn = ReferenceLineLayerFn | ExtendedReferenceLineLayerFn; - -export const commonReferenceLineLayerArgs: Omit = { - yConfig: { - types: [EXTENDED_Y_CONFIG], - help: strings.getRLYConfigHelp(), - multi: true, - }, - columnToLabel: { - types: ['string'], - help: strings.getColumnToLabelHelp(), - }, -}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts index 0921760f9f6765..2e2e6765734cf4 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts @@ -128,6 +128,11 @@ export const commonXYArgs: CommonXYFn['args'] = { types: ['string'], help: strings.getAriaLabelHelp(), }, + addTimeMarker: { + types: ['boolean'], + default: false, + help: strings.getAddTimeMakerHelp(), + }, markSizeRatio: { types: ['number'], help: strings.getMarkSizeRatioHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts index 5b943b0790313f..7f513168a8607e 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts @@ -13,62 +13,92 @@ import { LayerTypes } from '../constants'; import { extendedDataLayerFunction } from './extended_data_layer'; describe('extendedDataLayerConfig', () => { + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + }; + test('produces the correct arguments', async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, + const fullArgs: ExtendedDataLayerArgs = { + ...args, markSizeAccessor: 'b', + showPoints: true, + lineWidth: 10, + pointsRadius: 10, }; - const result = await extendedDataLayerFunction.fn(data, args, createMockExecutionContext()); + const result = await extendedDataLayerFunction.fn(data, fullArgs, createMockExecutionContext()); expect(result).toEqual({ type: 'extendedDataLayer', layerType: LayerTypes.DATA, - ...args, + ...fullArgs, table: data, }); }); test('throws the error if markSizeAccessor is provided to the not line/area chart', async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'bar', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, - markSizeAccessor: 'b', - }; expect( - extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', markSizeAccessor: 'b' }, + createMockExecutionContext() + ) ).rejects.toThrowErrorMatchingSnapshot(); }); test("throws the error if markSizeAccessor doesn't have the corresponding column in the table", async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, - markSizeAccessor: 'nonsense', - }; expect( - extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + extendedDataLayerFunction.fn( + data, + { ...args, markSizeAccessor: 'nonsense' }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if lineWidth is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', lineWidth: 10 }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if showPoints is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', showPoints: true }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if pointsRadius is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', pointsRadius: 10 }, + createMockExecutionContext() + ) ).rejects.toThrowErrorMatchingSnapshot(); }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts index 8e5019e065133a..f45aea7e86d8d6 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -10,7 +10,12 @@ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; import { getAccessors, normalizeTable } from '../helpers'; -import { validateMarkSizeForChartType } from './validate'; +import { + validateLineWidthForChartType, + validateMarkSizeForChartType, + validatePointsRadiusForChartType, + validateShowPointsForChartType, +} from './validate'; export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, context) => { const table = args.table ?? data; @@ -21,6 +26,9 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, accessors.accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); validateMarkSizeForChartType(args.markSizeAccessor, args.seriesType); validateAccessor(args.markSizeAccessor, table.columns); + validateLineWidthForChartType(args.lineWidth, args.seriesType); + validateShowPointsForChartType(args.showPoints, args.seriesType); + validatePointsRadiusForChartType(args.pointsRadius, args.seriesType); const normalizedTable = normalizeTable(table, accessors.xAccessor); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts deleted file mode 100644 index 41b264cf53a4db..00000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, EXTENDED_REFERENCE_LINE_LAYER } from '../constants'; -import { ExtendedReferenceLineLayerFn } from '../types'; -import { strings } from '../i18n'; -import { commonReferenceLineLayerArgs } from './common_reference_line_layer_args'; - -export const extendedReferenceLineLayerFunction: ExtendedReferenceLineLayerFn = { - name: EXTENDED_REFERENCE_LINE_LAYER, - aliases: [], - type: EXTENDED_REFERENCE_LINE_LAYER, - help: strings.getRLHelp(), - inputTypes: ['datatable'], - args: { - ...commonReferenceLineLayerArgs, - accessors: { - types: ['string'], - help: strings.getRLAccessorsHelp(), - multi: true, - }, - table: { - types: ['datatable'], - help: strings.getTableHelp(), - }, - layerId: { - types: ['string'], - help: strings.getLayerIdHelp(), - }, - }, - fn(input, args) { - const table = args.table ?? input; - const accessors = args.accessors ?? []; - accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); - - return { - type: EXTENDED_REFERENCE_LINE_LAYER, - ...args, - accessors: args.accessors ?? [], - layerType: LayerTypes.REFERENCELINE, - table, - }; - }, -}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts index 30a76217b5c0ea..dc82220db6e238 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts @@ -18,6 +18,6 @@ export * from './grid_lines_config'; export * from './axis_extent_config'; export * from './tick_labels_config'; export * from './labels_orientation_config'; +export * from './reference_line'; export * from './reference_line_layer'; -export * from './extended_reference_line_layer'; export * from './axis_titles_visibility_config'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts index 695bd16613715a..f419891e079ea7 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts @@ -6,10 +6,11 @@ * Side Public License, v 1. */ +import { i18n } from '@kbn/i18n'; import { LayeredXyVisFn } from '../types'; import { EXTENDED_DATA_LAYER, - EXTENDED_REFERENCE_LINE_LAYER, + REFERENCE_LINE_LAYER, LAYERED_XY_VIS, EXTENDED_ANNOTATION_LAYER, } from '../constants'; @@ -24,8 +25,10 @@ export const layeredXyVisFunction: LayeredXyVisFn = { args: { ...commonXYArgs, layers: { - types: [EXTENDED_DATA_LAYER, EXTENDED_REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER], - help: strings.getLayersHelp(), + types: [EXTENDED_DATA_LAYER, REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER], + help: i18n.translate('expressionXY.layeredXyVis.layers.help', { + defaultMessage: 'Layers of visual series', + }), multi: true, }, }, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts index 29624d80373932..fb7c91c682847a 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts @@ -7,15 +7,16 @@ */ import { XY_VIS_RENDERER } from '../constants'; -import { appendLayerIds, getDataLayers } from '../helpers'; import { LayeredXyVisFn } from '../types'; import { logDatatables } from '../utils'; import { validateMarkSizeRatioLimits, + validateAddTimeMarker, validateMinTimeBarInterval, hasBarLayer, errors, } from './validate'; +import { appendLayerIds, getDataLayers } from '../helpers'; export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) => { const layers = appendLayerIds(args.layers ?? [], 'layers'); @@ -24,6 +25,7 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) const dataLayers = getDataLayers(layers); const hasBar = hasBarLayer(dataLayers); + validateAddTimeMarker(dataLayers, args.addTimeMarker); validateMarkSizeRatioLimits(args.markSizeRatio); validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); const hasMarkSizeAccessors = diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts new file mode 100644 index 00000000000000..4c7c2e3dc628fd --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { ReferenceLineArgs, ReferenceLineConfigResult } from '../types'; +import { referenceLineFunction } from './reference_line'; + +describe('referenceLine', () => { + test('produces the correct arguments for minimum arguments', async () => { + const args: ReferenceLineArgs = { + value: 100, + fill: 'above', + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: false, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('produces the correct arguments for maximum arguments', async () => { + const args: ReferenceLineArgs = { + name: 'some value', + value: 100, + icon: 'alert', + iconPosition: 'below', + axisMode: 'bottom', + lineStyle: 'solid', + lineWidth: 10, + color: '#fff', + fill: 'below', + textVisibility: true, + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('adds text visibility if name is provided ', async () => { + const args: ReferenceLineArgs = { + name: 'some name', + value: 100, + fill: 'none', + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: true, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('hides text if textVisibility is true and no text is provided', async () => { + const args: ReferenceLineArgs = { + value: 100, + textVisibility: true, + fill: 'none', + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility: false, + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + test('applies text visibility if name is provided', async () => { + const checktextVisibility = (textVisibility: boolean = false) => { + const args: ReferenceLineArgs = { + value: 100, + name: 'some text', + textVisibility, + fill: 'none', + }; + + const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); + + const expectedResult: ReferenceLineConfigResult = { + type: 'referenceLine', + layerType: 'referenceLine', + lineLength: 0, + yConfig: [ + { + type: 'referenceLineYConfig', + ...args, + textVisibility, + }, + ], + }; + expect(result).toEqual(expectedResult); + }; + + checktextVisibility(); + checktextVisibility(true); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts new file mode 100644 index 00000000000000..c294d6ca5aaecf --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { + AvailableReferenceLineIcons, + FillStyles, + IconPositions, + LayerTypes, + LineStyles, + REFERENCE_LINE, + REFERENCE_LINE_Y_CONFIG, + YAxisModes, +} from '../constants'; +import { ReferenceLineFn } from '../types'; +import { strings } from '../i18n'; + +export const referenceLineFunction: ReferenceLineFn = { + name: REFERENCE_LINE, + aliases: [], + type: REFERENCE_LINE, + help: strings.getRLHelp(), + inputTypes: ['datatable', 'null'], + args: { + name: { + types: ['string'], + help: strings.getReferenceLineNameHelp(), + }, + value: { + types: ['number'], + help: strings.getReferenceLineValueHelp(), + required: true, + }, + axisMode: { + types: ['string'], + options: [...Object.values(YAxisModes)], + help: strings.getAxisModeHelp(), + default: YAxisModes.AUTO, + strict: true, + }, + color: { + types: ['string'], + help: strings.getColorHelp(), + }, + lineStyle: { + types: ['string'], + options: [...Object.values(LineStyles)], + help: i18n.translate('expressionXY.yConfig.lineStyle.help', { + defaultMessage: 'The style of the reference line', + }), + default: LineStyles.SOLID, + strict: true, + }, + lineWidth: { + types: ['number'], + help: i18n.translate('expressionXY.yConfig.lineWidth.help', { + defaultMessage: 'The width of the reference line', + }), + default: 1, + }, + icon: { + types: ['string'], + help: i18n.translate('expressionXY.yConfig.icon.help', { + defaultMessage: 'An optional icon used for reference lines', + }), + options: [...Object.values(AvailableReferenceLineIcons)], + strict: true, + }, + iconPosition: { + types: ['string'], + options: [...Object.values(IconPositions)], + help: i18n.translate('expressionXY.yConfig.iconPosition.help', { + defaultMessage: 'The placement of the icon for the reference line', + }), + default: IconPositions.AUTO, + strict: true, + }, + textVisibility: { + types: ['boolean'], + help: i18n.translate('expressionXY.yConfig.textVisibility.help', { + defaultMessage: 'Visibility of the label on the reference line', + }), + }, + fill: { + types: ['string'], + options: [...Object.values(FillStyles)], + help: i18n.translate('expressionXY.yConfig.fill.help', { + defaultMessage: 'Fill', + }), + default: FillStyles.NONE, + strict: true, + }, + }, + fn(table, args) { + const textVisibility = + args.name !== undefined && args.textVisibility === undefined + ? true + : args.name === undefined + ? false + : args.textVisibility; + + return { + type: REFERENCE_LINE, + layerType: LayerTypes.REFERENCELINE, + lineLength: table?.rows.length ?? 0, + yConfig: [{ ...args, textVisibility, type: REFERENCE_LINE_Y_CONFIG }], + }; + }, +}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts index 04c06f92d616f3..234001015d73a8 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts @@ -6,11 +6,9 @@ * Side Public License, v 1. */ -import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, REFERENCE_LINE_LAYER } from '../constants'; +import { REFERENCE_LINE_LAYER, EXTENDED_Y_CONFIG } from '../constants'; import { ReferenceLineLayerFn } from '../types'; import { strings } from '../i18n'; -import { commonReferenceLineLayerArgs } from './common_reference_line_layer_args'; export const referenceLineLayerFunction: ReferenceLineLayerFn = { name: REFERENCE_LINE_LAYER, @@ -19,23 +17,31 @@ export const referenceLineLayerFunction: ReferenceLineLayerFn = { help: strings.getRLHelp(), inputTypes: ['datatable'], args: { - ...commonReferenceLineLayerArgs, accessors: { - types: ['string', 'vis_dimension'], + types: ['string'], help: strings.getRLAccessorsHelp(), multi: true, }, + yConfig: { + types: [EXTENDED_Y_CONFIG], + help: strings.getRLYConfigHelp(), + multi: true, + }, + columnToLabel: { + types: ['string'], + help: strings.getColumnToLabelHelp(), + }, + table: { + types: ['datatable'], + help: strings.getTableHelp(), + }, + layerId: { + types: ['string'], + help: strings.getLayerIdHelp(), + }, }, - fn(table, args) { - const accessors = args.accessors ?? []; - accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); - - return { - type: REFERENCE_LINE_LAYER, - ...args, - layerType: LayerTypes.REFERENCELINE, - accessors, - table, - }; + async fn(input, args, context) { + const { referenceLineLayerFn } = await import('./reference_line_layer_fn'); + return await referenceLineLayerFn(input, args, context); }, }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts new file mode 100644 index 00000000000000..8b6d1cc5314470 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { LayerTypes, REFERENCE_LINE_LAYER } from '../constants'; +import { ReferenceLineLayerFn } from '../types'; + +export const referenceLineLayerFn: ReferenceLineLayerFn['fn'] = async (input, args, handlers) => { + const table = args.table ?? input; + const accessors = args.accessors ?? []; + accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + + return { + type: REFERENCE_LINE_LAYER, + ...args, + layerType: LayerTypes.REFERENCELINE, + table: args.table ?? input, + }; +}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index 60e590b0f8cca8..de01b149802b98 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -17,6 +17,7 @@ import { CommonXYDataLayerConfigResult, ValueLabelMode, CommonXYDataLayerConfig, + ExtendedDataLayerConfigResult, } from '../types'; import { isTimeChart } from '../helpers'; @@ -33,6 +34,27 @@ export const errors = { i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeLimitsError', { defaultMessage: 'Mark size ratio must be greater or equal to 1 and less or equal to 100', }), + lineWidthForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.lineWidthForNonLineOrAreaChartError', + { + defaultMessage: '`lineWidth` can be applied only for line or area charts', + } + ), + showPointsForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.showPointsForNonLineOrAreaChartError', + { + defaultMessage: '`showPoints` can be applied only for line or area charts', + } + ), + pointsRadiusForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.pointsRadiusForNonLineOrAreaChartError', + { + defaultMessage: '`pointsRadius` can be applied only for line or area charts', + } + ), markSizeRatioWithoutAccessor: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeRatioWithoutAccessor', { defaultMessage: 'Mark size ratio can be applied only with `markSizeAccessor`', @@ -58,6 +80,10 @@ export const errors = { i18n.translate('expressionXY.reusable.function.xyVis.errors.dataBoundsForNotLineChartError', { defaultMessage: 'Only line charts can be fit to the data bounds', }), + timeMarkerForNotTimeChartsError: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.timeMarkerForNotTimeChartsError', { + defaultMessage: 'Only time charts can have current time marker', + }), isInvalidIntervalError: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.isInvalidIntervalError', { defaultMessage: @@ -135,6 +161,18 @@ export const validateValueLabels = ( } }; +const isAreaOrLineChart = (seriesType: SeriesType) => + seriesType.includes('line') || seriesType.includes('area'); + +export const validateAddTimeMarker = ( + dataLayers: Array, + addTimeMarker?: boolean +) => { + if (addTimeMarker && !isTimeChart(dataLayers)) { + throw new Error(errors.timeMarkerForNotTimeChartsError()); + } +}; + export const validateMarkSizeForChartType = ( markSizeAccessor: ExpressionValueVisDimension | string | undefined, seriesType: SeriesType @@ -150,6 +188,33 @@ export const validateMarkSizeRatioLimits = (markSizeRatio?: number) => { } }; +export const validateLineWidthForChartType = ( + lineWidth: number | undefined, + seriesType: SeriesType +) => { + if (lineWidth !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.lineWidthForNonLineOrAreaChartError()); + } +}; + +export const validateShowPointsForChartType = ( + showPoints: boolean | undefined, + seriesType: SeriesType +) => { + if (showPoints !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.showPointsForNonLineOrAreaChartError()); + } +}; + +export const validatePointsRadiusForChartType = ( + pointsRadius: number | undefined, + seriesType: SeriesType +) => { + if (pointsRadius !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.pointsRadiusForNonLineOrAreaChartError()); + } +}; + export const validateMarkSizeRatioWithAccessor = ( markSizeRatio: number | undefined, markSizeAccessor: ExpressionValueVisDimension | string | undefined diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 8ec19614166389..174ff908eeaa18 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -7,7 +7,6 @@ */ import { xyVisFunction } from '.'; -import { Datatable } from '@kbn/expressions-plugin/common'; import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; import { sampleArgs, sampleLayer } from '../__mocks__'; import { XY_VIS } from '../constants'; @@ -15,26 +14,11 @@ import { XY_VIS } from '../constants'; describe('xyVis', () => { test('it renders with the specified data and args', async () => { const { data, args } = sampleArgs(); - const newData = { - ...data, - type: 'datatable', - - columns: data.columns.map((c) => - c.id !== 'c' - ? c - : { - ...c, - meta: { - type: 'string', - }, - } - ), - } as Datatable; const { layers, ...rest } = args; const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; const result = await xyVisFunction.fn( - newData, - { ...rest, ...restLayerArgs, referenceLineLayers: [], annotationLayers: [] }, + data, + { ...rest, ...restLayerArgs, referenceLines: [], annotationLayers: [] }, createMockExecutionContext() ); @@ -44,7 +28,7 @@ describe('xyVis', () => { value: { args: { ...rest, - layers: [{ layerType, table: newData, layerId: 'dataLayers-0', type, ...restLayerArgs }], + layers: [{ layerType, table: data, layerId: 'dataLayers-0', type, ...restLayerArgs }], }, }, }); @@ -60,7 +44,7 @@ describe('xyVis', () => { ...rest, ...{ ...sampleLayer, markSizeAccessor: 'b' }, markSizeRatio: 0, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -74,13 +58,14 @@ describe('xyVis', () => { ...rest, ...{ ...sampleLayer, markSizeAccessor: 'b' }, markSizeRatio: 101, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() ) ).rejects.toThrowErrorMatchingSnapshot(); }); + test('it should throw error if minTimeBarInterval is invalid', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; @@ -92,7 +77,7 @@ describe('xyVis', () => { ...rest, ...restLayerArgs, minTimeBarInterval: '1q', - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -111,7 +96,26 @@ describe('xyVis', () => { ...rest, ...restLayerArgs, minTimeBarInterval: '1h', - referenceLineLayers: [], + referenceLines: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('it should throw error if addTimeMarker applied for not time chart', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + addTimeMarker: true, + referenceLines: [], annotationLayers: [], }, createMockExecutionContext() @@ -131,7 +135,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], splitRowAccessor, }, @@ -152,7 +156,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], splitColumnAccessor, }, @@ -172,7 +176,7 @@ describe('xyVis', () => { { ...rest, ...restLayerArgs, - referenceLineLayers: [], + referenceLines: [], annotationLayers: [], markSizeRatio: 5, }, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts index 37baf028178ccb..7d2783cf6f1cde 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts @@ -7,7 +7,7 @@ */ import { XyVisFn } from '../types'; -import { XY_VIS, REFERENCE_LINE_LAYER, ANNOTATION_LAYER } from '../constants'; +import { XY_VIS, REFERENCE_LINE, ANNOTATION_LAYER } from '../constants'; import { strings } from '../i18n'; import { commonXYArgs } from './common_xy_args'; import { commonDataLayerArgs } from './common_data_layer_args'; @@ -33,9 +33,9 @@ export const xyVisFunction: XyVisFn = { help: strings.getAccessorsHelp(), multi: true, }, - referenceLineLayers: { - types: [REFERENCE_LINE_LAYER], - help: strings.getReferenceLineLayerHelp(), + referenceLines: { + types: [REFERENCE_LINE], + help: strings.getReferenceLinesHelp(), multi: true, }, annotationLayers: { diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index e879f33b76548f..afe569a86f894b 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -13,7 +13,7 @@ import { } from '@kbn/visualizations-plugin/common/utils'; import type { Datatable } from '@kbn/expressions-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; -import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER } from '../constants'; +import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER, REFERENCE_LINE } from '../constants'; import { appendLayerIds, getAccessors, normalizeTable } from '../helpers'; import { DataLayerConfigResult, XYLayerConfig, XyVisFn, XYArgs } from '../types'; import { getLayerDimensions } from '../utils'; @@ -25,9 +25,13 @@ import { validateFillOpacity, validateMarkSizeRatioLimits, validateValueLabels, + validateAddTimeMarker, validateMinTimeBarInterval, validateMarkSizeForChartType, validateMarkSizeRatioWithAccessor, + validateShowPointsForChartType, + validateLineWidthForChartType, + validatePointsRadiusForChartType, } from './validate'; const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => { @@ -42,6 +46,9 @@ const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult isHistogram: args.isHistogram, palette: args.palette, yConfig: args.yConfig, + showPoints: args.showPoints, + pointsRadius: args.pointsRadius, + lineWidth: args.lineWidth, layerType: LayerTypes.DATA, table: normalizedTable, ...accessors, @@ -53,7 +60,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateAccessor(args.splitColumnAccessor, data.columns); const { - referenceLineLayers = [], + referenceLines = [], annotationLayers = [], // data_layer args seriesType, @@ -67,6 +74,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { yConfig, palette, markSizeAccessor, + showPoints, + pointsRadius, + lineWidth, ...restArgs } = args; @@ -81,7 +91,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { const layers: XYLayerConfig[] = [ ...appendLayerIds(dataLayers, 'dataLayers'), - ...appendLayerIds(referenceLineLayers, 'referenceLineLayers'), + ...appendLayerIds(referenceLines, 'referenceLines'), ...appendLayerIds(annotationLayers, 'annotationLayers'), ]; @@ -90,7 +100,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { handlers.inspectorAdapters.tables.allowCsvExport = true; const layerDimensions = layers.reduce((dimensions, layer) => { - if (layer.layerType === LayerTypes.ANNOTATIONS) { + if (layer.layerType === LayerTypes.ANNOTATIONS || layer.type === REFERENCE_LINE) { return dimensions; } @@ -107,6 +117,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateExtent(args.yLeftExtent, hasBar || hasArea, dataLayers); validateExtent(args.yRightExtent, hasBar || hasArea, dataLayers); validateFillOpacity(args.fillOpacity, hasArea); + validateAddTimeMarker(dataLayers, args.addTimeMarker); validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); const hasNotHistogramBars = !hasHistogramBarLayer(dataLayers); @@ -114,6 +125,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateValueLabels(args.valueLabels, hasBar, hasNotHistogramBars); validateMarkSizeRatioWithAccessor(args.markSizeRatio, dataLayers[0].markSizeAccessor); validateMarkSizeRatioLimits(args.markSizeRatio); + validateLineWidthForChartType(lineWidth, args.seriesType); + validateShowPointsForChartType(showPoints, args.seriesType); + validatePointsRadiusForChartType(pointsRadius, args.seriesType); return { type: 'render', diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts index a3eea973fbf912..895abdb7a60df4 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.test.ts @@ -63,7 +63,7 @@ describe('#getDataLayers', () => { palette: { type: 'system_palette', name: 'system' }, }, { - type: 'extendedReferenceLineLayer', + type: 'referenceLineLayer', layerType: 'referenceLine', accessors: ['y'], table: { rows: [], columns: [], type: 'datatable' }, diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts index 8ddbc4bc97f104..66d4c11a9f7ae9 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts @@ -6,13 +6,16 @@ * Side Public License, v 1. */ +import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { XScaleTypes } from '../constants'; import { CommonXYDataLayerConfigResult } from '../types'; export function isTimeChart(layers: CommonXYDataLayerConfigResult[]) { return layers.every( (l): l is CommonXYDataLayerConfigResult => - l.table.columns.find((col) => col.id === l.xAccessor)?.meta.type === 'date' && - l.xScaleType === XScaleTypes.TIME + (l.xAccessor + ? getColumnByAccessor(l.xAccessor, l.table.columns)?.meta.type === 'date' + : false) && + (!l.xScaleType || l.xScaleType === XScaleTypes.TIME) ); } diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index f3425ec2db625e..4f94d5805396df 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -93,9 +93,9 @@ export const strings = { i18n.translate('expressionXY.xyVis.dataLayer.help', { defaultMessage: 'Data layer of visual series', }), - getReferenceLineLayerHelp: () => - i18n.translate('expressionXY.xyVis.referenceLineLayer.help', { - defaultMessage: 'Reference line layer', + getReferenceLinesHelp: () => + i18n.translate('expressionXY.xyVis.referenceLines.help', { + defaultMessage: 'Reference line', }), getAnnotationLayerHelp: () => i18n.translate('expressionXY.xyVis.annotationLayer.help', { @@ -121,6 +121,10 @@ export const strings = { i18n.translate('expressionXY.xyVis.ariaLabel.help', { defaultMessage: 'Specifies the aria label of the xy chart', }), + getAddTimeMakerHelp: () => + i18n.translate('expressionXY.xyVis.addTimeMaker.help', { + defaultMessage: 'Show time marker', + }), getMarkSizeRatioHelp: () => i18n.translate('expressionXY.xyVis.markSizeRatio.help', { defaultMessage: 'Specifies the ratio of the dots at the line and area charts', @@ -177,6 +181,18 @@ export const strings = { i18n.translate('expressionXY.dataLayer.markSizeAccessor.help', { defaultMessage: 'Mark size accessor', }), + getLineWidthHelp: () => + i18n.translate('expressionXY.dataLayer.lineWidth.help', { + defaultMessage: 'Line width', + }), + getShowPointsHelp: () => + i18n.translate('expressionXY.dataLayer.showPoints.help', { + defaultMessage: 'Show points', + }), + getPointsRadiusHelp: () => + i18n.translate('expressionXY.dataLayer.pointsRadius.help', { + defaultMessage: 'Points radius', + }), getYConfigHelp: () => i18n.translate('expressionXY.dataLayer.yConfig.help', { defaultMessage: 'Additional configuration for y axes', @@ -237,4 +253,12 @@ export const strings = { i18n.translate('expressionXY.annotationLayer.annotations.help', { defaultMessage: 'Annotations', }), + getReferenceLineNameHelp: () => + i18n.translate('expressionXY.referenceLine.name.help', { + defaultMessage: 'Reference line name', + }), + getReferenceLineValueHelp: () => + i18n.translate('expressionXY.referenceLine.Value.help', { + defaultMessage: 'Reference line value', + }), }; diff --git a/src/plugins/chart_expressions/expression_xy/common/index.ts b/src/plugins/chart_expressions/expression_xy/common/index.ts index 7211a7a7db1b76..005f6c2867c180 100755 --- a/src/plugins/chart_expressions/expression_xy/common/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/index.ts @@ -58,6 +58,5 @@ export type { ReferenceLineLayerConfigResult, CommonXYReferenceLineLayerConfig, AxisTitlesVisibilityConfigResult, - ExtendedReferenceLineLayerConfigResult, CommonXYReferenceLineLayerConfigResult, } from './types'; diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 0e10f680811ec9..502bb39cda894b 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -26,7 +26,7 @@ import { XYCurveTypes, YAxisModes, YScaleTypes, - REFERENCE_LINE_LAYER, + REFERENCE_LINE, Y_CONFIG, AXIS_TITLES_VISIBILITY_CONFIG, LABELS_ORIENTATION_CONFIG, @@ -36,7 +36,7 @@ import { DATA_LAYER, AXIS_EXTENT_CONFIG, EXTENDED_DATA_LAYER, - EXTENDED_REFERENCE_LINE_LAYER, + REFERENCE_LINE_LAYER, ANNOTATION_LAYER, EndValues, EXTENDED_Y_CONFIG, @@ -44,6 +44,7 @@ import { XY_VIS, LAYERED_XY_VIS, EXTENDED_ANNOTATION_LAYER, + REFERENCE_LINE_Y_CONFIG, } from '../constants'; import { XYRender } from './expression_renderers'; @@ -101,6 +102,9 @@ export interface DataLayerArgs { hide?: boolean; splitAccessor?: string | ExpressionValueVisDimension; markSizeAccessor?: string | ExpressionValueVisDimension; + lineWidth?: number; + showPoints?: boolean; + pointsRadius?: number; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -120,6 +124,9 @@ export interface ExtendedDataLayerArgs { hide?: boolean; splitAccessor?: string; markSizeAccessor?: string; + lineWidth?: number; + showPoints?: boolean; + pointsRadius?: number; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -194,7 +201,7 @@ export interface XYArgs extends DataLayerArgs { endValue?: EndValue; emphasizeFitting?: boolean; valueLabels: ValueLabelMode; - referenceLineLayers: ReferenceLineLayerConfigResult[]; + referenceLines: ReferenceLineConfigResult[]; annotationLayers: AnnotationLayerConfigResult[]; fittingFunction?: FittingFunction; axisTitlesVisibilitySettings?: AxisTitlesVisibilityConfigResult; @@ -206,6 +213,7 @@ export interface XYArgs extends DataLayerArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + addTimeMarker?: boolean; markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; @@ -235,6 +243,7 @@ export interface LayeredXYArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + addTimeMarker?: boolean; markSizeRatio?: number; minTimeBarInterval?: string; } @@ -262,6 +271,7 @@ export interface XYProps { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + addTimeMarker?: boolean; markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; @@ -287,13 +297,13 @@ export type ExtendedAnnotationLayerConfigResult = ExtendedAnnotationLayerArgs & layerType: typeof LayerTypes.ANNOTATIONS; }; -export interface ReferenceLineLayerArgs { - accessors: Array; - columnToLabel?: string; - yConfig?: ExtendedYConfigResult[]; +export interface ReferenceLineArgs extends Omit { + name?: string; + value: number; + fill: FillStyle; } -export interface ExtendedReferenceLineLayerArgs { +export interface ReferenceLineLayerArgs { layerId?: string; accessors: string[]; columnToLabel?: string; @@ -301,26 +311,31 @@ export interface ExtendedReferenceLineLayerArgs { table?: Datatable; } -export type XYLayerArgs = DataLayerArgs | ReferenceLineLayerArgs | AnnotationLayerArgs; -export type XYLayerConfig = DataLayerConfig | ReferenceLineLayerConfig | AnnotationLayerConfig; +export type XYLayerArgs = DataLayerArgs | ReferenceLineArgs | AnnotationLayerArgs; +export type XYLayerConfig = DataLayerConfig | ReferenceLineConfig | AnnotationLayerConfig; export type XYExtendedLayerConfig = | ExtendedDataLayerConfig - | ExtendedReferenceLineLayerConfig + | ReferenceLineLayerConfig | ExtendedAnnotationLayerConfig; export type XYExtendedLayerConfigResult = | ExtendedDataLayerConfigResult - | ExtendedReferenceLineLayerConfigResult + | ReferenceLineLayerConfigResult | ExtendedAnnotationLayerConfigResult; -export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { - type: typeof REFERENCE_LINE_LAYER; +export interface ReferenceLineYConfig extends ReferenceLineArgs { + type: typeof REFERENCE_LINE_Y_CONFIG; +} + +export interface ReferenceLineConfigResult { + type: typeof REFERENCE_LINE; layerType: typeof LayerTypes.REFERENCELINE; - table: Datatable; -}; + lineLength: number; + yConfig: [ReferenceLineYConfig]; +} -export type ExtendedReferenceLineLayerConfigResult = ExtendedReferenceLineLayerArgs & { - type: typeof EXTENDED_REFERENCE_LINE_LAYER; +export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { + type: typeof REFERENCE_LINE_LAYER; layerType: typeof LayerTypes.REFERENCELINE; table: Datatable; }; @@ -337,11 +352,11 @@ export interface WithLayerId { } export type DataLayerConfig = DataLayerConfigResult & WithLayerId; -export type ReferenceLineLayerConfig = ReferenceLineLayerConfigResult & WithLayerId; +export type ReferenceLineConfig = ReferenceLineConfigResult & WithLayerId; export type AnnotationLayerConfig = AnnotationLayerConfigResult & WithLayerId; export type ExtendedDataLayerConfig = ExtendedDataLayerConfigResult & WithLayerId; -export type ExtendedReferenceLineLayerConfig = ExtendedReferenceLineLayerConfigResult & WithLayerId; +export type ReferenceLineLayerConfig = ReferenceLineLayerConfigResult & WithLayerId; export type ExtendedAnnotationLayerConfig = ExtendedAnnotationLayerConfigResult & WithLayerId; export type ExtendedDataLayerConfigResult = Omit & { @@ -370,13 +385,11 @@ export type TickLabelsConfigResult = AxesSettingsConfig & { type: typeof TICK_LA export type CommonXYLayerConfig = XYLayerConfig | XYExtendedLayerConfig; export type CommonXYDataLayerConfigResult = DataLayerConfigResult | ExtendedDataLayerConfigResult; export type CommonXYReferenceLineLayerConfigResult = - | ReferenceLineLayerConfigResult - | ExtendedReferenceLineLayerConfigResult; + | ReferenceLineConfigResult + | ReferenceLineLayerConfigResult; export type CommonXYDataLayerConfig = DataLayerConfig | ExtendedDataLayerConfig; -export type CommonXYReferenceLineLayerConfig = - | ReferenceLineLayerConfig - | ExtendedReferenceLineLayerConfig; +export type CommonXYReferenceLineLayerConfig = ReferenceLineConfig | ReferenceLineLayerConfig; export type CommonXYAnnotationLayerConfig = AnnotationLayerConfig | ExtendedAnnotationLayerConfig; @@ -400,17 +413,17 @@ export type ExtendedDataLayerFn = ExpressionFunctionDefinition< Promise >; +export type ReferenceLineFn = ExpressionFunctionDefinition< + typeof REFERENCE_LINE, + Datatable | null, + ReferenceLineArgs, + ReferenceLineConfigResult +>; export type ReferenceLineLayerFn = ExpressionFunctionDefinition< typeof REFERENCE_LINE_LAYER, Datatable, ReferenceLineLayerArgs, - ReferenceLineLayerConfigResult ->; -export type ExtendedReferenceLineLayerFn = ExpressionFunctionDefinition< - typeof EXTENDED_REFERENCE_LINE_LAYER, - Datatable, - ExtendedReferenceLineLayerArgs, - ExtendedReferenceLineLayerConfigResult + Promise >; export type YConfigFn = ExpressionFunctionDefinition; diff --git a/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts b/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts index 79a3cbd2eef196..44026b30ed4932 100644 --- a/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts +++ b/src/plugins/chart_expressions/expression_xy/common/utils/log_datatables.ts @@ -8,13 +8,9 @@ import { ExecutionContext } from '@kbn/expressions-plugin'; import { Dimension, prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes } from '../constants'; +import { LayerTypes, REFERENCE_LINE } from '../constants'; import { strings } from '../i18n'; -import { - CommonXYDataLayerConfig, - CommonXYLayerConfig, - CommonXYReferenceLineLayerConfig, -} from '../types'; +import { CommonXYDataLayerConfig, CommonXYLayerConfig, ReferenceLineLayerConfig } from '../types'; export const logDatatables = (layers: CommonXYLayerConfig[], handlers: ExecutionContext) => { if (!handlers?.inspectorAdapters?.tables) { @@ -25,16 +21,17 @@ export const logDatatables = (layers: CommonXYLayerConfig[], handlers: Execution handlers.inspectorAdapters.tables.allowCsvExport = true; layers.forEach((layer) => { - if (layer.layerType === LayerTypes.ANNOTATIONS) { + if (layer.layerType === LayerTypes.ANNOTATIONS || layer.type === REFERENCE_LINE) { return; } + const logTable = prepareLogTable(layer.table, getLayerDimensions(layer), true); handlers.inspectorAdapters.tables.logDatatable(layer.layerId, logTable); }); }; export const getLayerDimensions = ( - layer: CommonXYDataLayerConfig | CommonXYReferenceLineLayerConfig + layer: CommonXYDataLayerConfig | ReferenceLineLayerConfig ): Dimension[] => { let xAccessor; let splitAccessor; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index e7a26ec20bbfc1..c3d1fc980ad01e 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -334,6 +334,10 @@ exports[`XYChart component it renders area 1`] = ` } } /> + + + + + + + + + + = ({ @@ -67,6 +69,7 @@ export const DataLayers: FC = ({ shouldShowValueLabels, formattedDatatables, chartHasMoreThanOneBarSeries, + defaultXScaleType, }) => { const colorAssignments = getColorAssignments(layers, formatFactory); return ( @@ -104,6 +107,7 @@ export const DataLayers: FC = ({ timeZone, emphasizeFitting, fillOpacity, + defaultXScaleType, }); const index = `${layer.layerId}-${accessorIndex}`; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx deleted file mode 100644 index 23e5011fe54a70..00000000000000 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.test.tsx +++ /dev/null @@ -1,369 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { LineAnnotation, RectAnnotation } from '@elastic/charts'; -import { shallow } from 'enzyme'; -import React from 'react'; -import { Datatable } from '@kbn/expressions-plugin/common'; -import { FieldFormat } from '@kbn/field-formats-plugin/common'; -import { LayerTypes } from '../../common/constants'; -import { - ReferenceLineLayerArgs, - ReferenceLineLayerConfig, - ExtendedYConfig, -} from '../../common/types'; -import { ReferenceLineAnnotations, ReferenceLineAnnotationsProps } from './reference_lines'; - -const row: Record = { - xAccessorFirstId: 1, - xAccessorSecondId: 2, - yAccessorLeftFirstId: 5, - yAccessorLeftSecondId: 10, - yAccessorRightFirstId: 5, - yAccessorRightSecondId: 10, -}; - -const data: Datatable = { - type: 'datatable', - rows: [row], - columns: Object.keys(row).map((id) => ({ - id, - name: `Static value: ${row[id]}`, - meta: { - type: 'number', - params: { id: 'number' }, - }, - })), -}; - -function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerConfig[] { - return [ - { - layerId: 'first', - accessors: (yConfigs || []).map(({ forAccessor }) => forAccessor), - yConfig: yConfigs, - type: 'referenceLineLayer', - layerType: LayerTypes.REFERENCELINE, - table: data, - }, - ]; -} - -interface YCoords { - y0: number | undefined; - y1: number | undefined; -} -interface XCoords { - x0: number | undefined; - x1: number | undefined; -} - -function getAxisFromId(layerPrefix: string): ExtendedYConfig['axisMode'] { - return /left/i.test(layerPrefix) ? 'left' : /right/i.test(layerPrefix) ? 'right' : 'bottom'; -} - -const emptyCoords = { x0: undefined, x1: undefined, y0: undefined, y1: undefined }; - -describe('ReferenceLineAnnotations', () => { - describe('with fill', () => { - let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; - let defaultProps: Omit; - - beforeEach(() => { - formatters = { - left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, - }; - - defaultProps = { - formatters, - isHorizontal: false, - axesMap: { left: true, right: false }, - paddingMap: {}, - }; - }); - - it.each([ - ['yAccessorLeft', 'above'], - ['yAccessorLeft', 'below'], - ['yAccessorRight', 'above'], - ['yAccessorRight', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( - 'should render a RectAnnotation for a reference line with fill set: %s %s', - (layerPrefix, fill) => { - const axisMode = getAxisFromId(layerPrefix); - const wrapper = shallow( - - ); - - const y0 = fill === 'above' ? 5 : undefined; - const y1 = fill === 'above' ? undefined : 5; - - expect(wrapper.find(LineAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { x0: undefined, x1: undefined, y0, y1 }, - details: y0 ?? y1, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['xAccessor', 'above'], - ['xAccessor', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( - 'should render a RectAnnotation for a reference line with fill set: %s %s', - (layerPrefix, fill) => { - const wrapper = shallow( - - ); - - const x0 = fill === 'above' ? 1 : undefined; - const x1 = fill === 'above' ? undefined : 1; - - expect(wrapper.find(LineAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).exists()).toBe(true); - expect(wrapper.find(RectAnnotation).prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, x0, x1 }, - details: x0 ?? x1, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['yAccessorLeft', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( - 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', - (layerPrefix, fill, coordsA, coordsB) => { - const axisMode = getAxisFromId(layerPrefix); - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.y0 ?? coordsA.y1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.y1 ?? coordsB.y0, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }], - ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }], - ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( - 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', - (layerPrefix, fill, coordsA, coordsB) => { - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.x0 ?? coordsA.x1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.x1 ?? coordsB.x0, - header: undefined, - }, - ]) - ); - } - ); - - it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( - 'should let areas in different directions overlap: %s', - (layerPrefix) => { - const axisMode = getAxisFromId(layerPrefix); - - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x0: 1 } : { y0: 5 }) }, - details: axisMode === 'bottom' ? 1 : 5, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x1: 2 } : { y1: 10 }) }, - details: axisMode === 'bottom' ? 2 : 10, - header: undefined, - }, - ]) - ); - } - ); - - it.each([ - ['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], - ['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[ExtendedYConfig['fill'], YCoords, YCoords]>)( - 'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s', - (fill, coordsA, coordsB) => { - const wrapper = shallow( - - ); - - expect(wrapper.find(RectAnnotation).first().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsA }, - details: coordsA.y0 ?? coordsA.y1, - header: undefined, - }, - ]) - ); - expect(wrapper.find(RectAnnotation).last().prop('dataValues')).toEqual( - expect.arrayContaining([ - { - coordinates: { ...emptyCoords, ...coordsB }, - details: coordsB.y1 ?? coordsB.y0, - header: undefined, - }, - ]) - ); - } - ); - }); -}); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx deleted file mode 100644 index d17dbf2a70ad17..00000000000000 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import './reference_lines.scss'; - -import React from 'react'; -import { groupBy } from 'lodash'; -import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts'; -import { euiLightVars } from '@kbn/ui-theme'; -import type { FieldFormat } from '@kbn/field-formats-plugin/common'; -import type { CommonXYReferenceLineLayerConfig, IconPosition, YAxisMode } from '../../common/types'; -import { - LINES_MARKER_SIZE, - mapVerticalToHorizontalPlacement, - Marker, - MarkerBody, -} from '../helpers'; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; - -// if there's just one axis, put it on the other one -// otherwise use the same axis -// this function assume the chart is vertical -export function getBaseIconPlacement( - iconPosition: IconPosition | undefined, - axesMap?: Record, - axisMode?: YAxisMode -) { - if (iconPosition === 'auto') { - if (axisMode === 'bottom') { - return Position.Top; - } - if (axesMap) { - if (axisMode === 'left') { - return axesMap.right ? Position.Left : Position.Right; - } - return axesMap.left ? Position.Right : Position.Left; - } - } - - if (iconPosition === 'left') { - return Position.Left; - } - if (iconPosition === 'right') { - return Position.Right; - } - if (iconPosition === 'below') { - return Position.Bottom; - } - return Position.Top; -} - -export interface ReferenceLineAnnotationsProps { - layers: CommonXYReferenceLineLayerConfig[]; - formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; - axesMap: Record<'left' | 'right', boolean>; - isHorizontal: boolean; - paddingMap: Partial>; -} - -export const ReferenceLineAnnotations = ({ - layers, - formatters, - axesMap, - isHorizontal, - paddingMap, -}: ReferenceLineAnnotationsProps) => { - return ( - <> - {layers.flatMap((layer) => { - if (!layer.yConfig) { - return []; - } - const { columnToLabel, yConfig: yConfigs, table } = layer; - const columnToLabelMap: Record = columnToLabel - ? JSON.parse(columnToLabel) - : {}; - - const row = table.rows[0]; - - const yConfigByValue = yConfigs.sort( - ({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB] - ); - - const groupedByDirection = groupBy(yConfigByValue, 'fill'); - if (groupedByDirection.below) { - groupedByDirection.below.reverse(); - } - - return yConfigByValue.flatMap((yConfig, i) => { - // Find the formatter for the given axis - const groupId = - yConfig.axisMode === 'bottom' - ? undefined - : yConfig.axisMode === 'right' - ? 'right' - : 'left'; - - const formatter = formatters[groupId || 'bottom']; - - const defaultColor = euiLightVars.euiColorDarkShade; - - // get the position for vertical chart - const markerPositionVertical = getBaseIconPlacement( - yConfig.iconPosition, - axesMap, - yConfig.axisMode - ); - // the padding map is built for vertical chart - const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; - - const props = { - groupId, - marker: ( - - ), - markerBody: ( - - ), - // rotate the position if required - markerPosition: isHorizontal - ? mapVerticalToHorizontalPlacement(markerPositionVertical) - : markerPositionVertical, - }; - const annotations = []; - - const sharedStyle = { - strokeWidth: yConfig.lineWidth || 1, - stroke: yConfig.color || defaultColor, - dash: - yConfig.lineStyle === 'dashed' - ? [(yConfig.lineWidth || 1) * 3, yConfig.lineWidth || 1] - : yConfig.lineStyle === 'dotted' - ? [yConfig.lineWidth || 1, yConfig.lineWidth || 1] - : undefined, - }; - - annotations.push( - ({ - dataValue: row[yConfig.forAccessor], - header: columnToLabelMap[yConfig.forAccessor], - details: formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }))} - domainType={ - yConfig.axisMode === 'bottom' - ? AnnotationDomainType.XDomain - : AnnotationDomainType.YDomain - } - style={{ - line: { - ...sharedStyle, - opacity: 1, - }, - }} - /> - ); - - if (yConfig.fill && yConfig.fill !== 'none') { - const isFillAbove = yConfig.fill === 'above'; - const indexFromSameType = groupedByDirection[yConfig.fill].findIndex( - ({ forAccessor }) => forAccessor === yConfig.forAccessor - ); - const shouldCheckNextReferenceLine = - indexFromSameType < groupedByDirection[yConfig.fill].length - 1; - annotations.push( - { - const nextValue = shouldCheckNextReferenceLine - ? row[groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor] - : undefined; - if (yConfig.axisMode === 'bottom') { - return { - coordinates: { - x0: isFillAbove ? row[yConfig.forAccessor] : nextValue, - y0: undefined, - x1: isFillAbove ? nextValue : row[yConfig.forAccessor], - y1: undefined, - }, - header: columnToLabelMap[yConfig.forAccessor], - details: - formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }; - } - return { - coordinates: { - x0: undefined, - y0: isFillAbove ? row[yConfig.forAccessor] : nextValue, - x1: undefined, - y1: isFillAbove ? nextValue : row[yConfig.forAccessor], - }, - header: columnToLabelMap[yConfig.forAccessor], - details: - formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], - }; - })} - style={{ - ...sharedStyle, - fill: yConfig.color || defaultColor, - opacity: 0.1, - }} - /> - ); - } - return annotations; - }); - })} - - ); -}; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts similarity index 73% rename from packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx rename to src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts index 6719fffa36740b..62b3b31bf8bd57 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/index.ts @@ -6,5 +6,5 @@ * Side Public License, v 1. */ -export { NoDataViews } from './no_data_views'; -export { NoDataViews as NoDataViewsComponent } from './no_data_views.component'; +export * from './reference_lines'; +export * from './utils'; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx new file mode 100644 index 00000000000000..30f4a97986ec33 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { Position } from '@elastic/charts'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { ReferenceLineConfig } from '../../../common/types'; +import { getGroupId } from './utils'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +interface ReferenceLineProps { + layer: ReferenceLineConfig; + paddingMap: Partial>; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; + nextValue?: number; +} + +export const ReferenceLine: FC = ({ + layer, + axesMap, + formatters, + paddingMap, + isHorizontal, + nextValue, +}) => { + const { + yConfig: [yConfig], + } = layer; + + if (!yConfig) { + return null; + } + + const { axisMode, value } = yConfig; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + + const formatter = formatters[groupId || 'bottom']; + const id = `${layer.layerId}-${value}`; + + return ( + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx new file mode 100644 index 00000000000000..b5b94b4c2df51a --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_annotations.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { AnnotationDomainType, LineAnnotation, Position, RectAnnotation } from '@elastic/charts'; +import { euiLightVars } from '@kbn/ui-theme'; +import React, { FC } from 'react'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { LINES_MARKER_SIZE } from '../../helpers'; +import { + AvailableReferenceLineIcon, + FillStyle, + IconPosition, + LineStyle, + YAxisMode, +} from '../../../common/types'; +import { + getBaseIconPlacement, + getBottomRect, + getGroupId, + getHorizontalRect, + getLineAnnotationProps, + getSharedStyle, +} from './utils'; + +export interface ReferenceLineAnnotationConfig { + id: string; + name?: string; + value: number; + nextValue?: number; + icon?: AvailableReferenceLineIcon; + lineWidth?: number; + lineStyle?: LineStyle; + fill?: FillStyle; + iconPosition?: IconPosition; + textVisibility?: boolean; + axisMode?: YAxisMode; + color?: string; +} + +interface Props { + config: ReferenceLineAnnotationConfig; + paddingMap: Partial>; + formatter?: FieldFormat; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; +} + +const getRectDataValue = ( + annotationConfig: ReferenceLineAnnotationConfig, + formatter: FieldFormat | undefined +) => { + const { name, value, nextValue, fill, axisMode } = annotationConfig; + const isFillAbove = fill === 'above'; + + if (axisMode === 'bottom') { + return getBottomRect(name, isFillAbove, formatter, value, nextValue); + } + + return getHorizontalRect(name, isFillAbove, formatter, value, nextValue); +}; + +export const ReferenceLineAnnotations: FC = ({ + config, + axesMap, + formatter, + paddingMap, + isHorizontal, +}) => { + const { id, axisMode, iconPosition, name, textVisibility, value, fill, color } = config; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + const defaultColor = euiLightVars.euiColorDarkShade; + // get the position for vertical chart + const markerPositionVertical = getBaseIconPlacement(iconPosition, axesMap, axisMode); + // the padding map is built for vertical chart + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + + const props = getLineAnnotationProps( + config, + { + markerLabel: name, + markerBodyLabel: textVisibility && !hasReducedPadding ? name : undefined, + }, + axesMap, + paddingMap, + groupId, + isHorizontal + ); + + const sharedStyle = getSharedStyle(config); + + const dataValues = { + dataValue: value, + header: name, + details: formatter?.convert(value) || value.toString(), + }; + + const line = ( + + ); + + let rect; + if (fill && fill !== 'none') { + const rectDataValues = getRectDataValue(config, formatter); + + rect = ( + + ); + } + return ( + <> + {line} + {rect} + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx new file mode 100644 index 00000000000000..210f5bda0126bf --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line_layer.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC } from 'react'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { groupBy } from 'lodash'; +import { Position } from '@elastic/charts'; +import { ReferenceLineLayerConfig } from '../../../common/types'; +import { getGroupId } from './utils'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +interface ReferenceLineLayerProps { + layer: ReferenceLineLayerConfig; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + paddingMap: Partial>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; +} + +export const ReferenceLineLayer: FC = ({ + layer, + formatters, + paddingMap, + axesMap, + isHorizontal, +}) => { + if (!layer.yConfig) { + return null; + } + + const { columnToLabel, yConfig: yConfigs, table } = layer; + const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; + + const row = table.rows[0]; + + const yConfigByValue = yConfigs.sort( + ({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB] + ); + + const groupedByDirection = groupBy(yConfigByValue, 'fill'); + if (groupedByDirection.below) { + groupedByDirection.below.reverse(); + } + + const referenceLineElements = yConfigByValue.flatMap((yConfig) => { + const { axisMode } = yConfig; + + // Find the formatter for the given axis + const groupId = getGroupId(axisMode); + + const formatter = formatters[groupId || 'bottom']; + const name = columnToLabelMap[yConfig.forAccessor]; + const value = row[yConfig.forAccessor]; + const yConfigsWithSameDirection = groupedByDirection[yConfig.fill!]; + const indexFromSameType = yConfigsWithSameDirection.findIndex( + ({ forAccessor }) => forAccessor === yConfig.forAccessor + ); + + const shouldCheckNextReferenceLine = indexFromSameType < yConfigsWithSameDirection.length - 1; + + const nextValue = shouldCheckNextReferenceLine + ? row[yConfigsWithSameDirection[indexFromSameType + 1].forAccessor] + : undefined; + + const { forAccessor, type, ...restAnnotationConfig } = yConfig; + const id = `${layer.layerId}-${yConfig.forAccessor}`; + + return ( + + ); + }); + + return <>{referenceLineElements}; +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines.scss b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.scss similarity index 100% rename from src/plugins/chart_expressions/expression_xy/public/components/reference_lines.scss rename to src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.scss diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx new file mode 100644 index 00000000000000..ec657ee293e691 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.test.tsx @@ -0,0 +1,683 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LineAnnotation, RectAnnotation } from '@elastic/charts'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { Datatable } from '@kbn/expressions-plugin/common'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { LayerTypes } from '../../../common/constants'; +import { + ReferenceLineLayerArgs, + ReferenceLineLayerConfig, + ExtendedYConfig, + ReferenceLineArgs, + ReferenceLineConfig, +} from '../../../common/types'; +import { ReferenceLines, ReferenceLinesProps } from './reference_lines'; +import { ReferenceLineLayer } from './reference_line_layer'; +import { ReferenceLine } from './reference_line'; +import { ReferenceLineAnnotations } from './reference_line_annotations'; + +const row: Record = { + xAccessorFirstId: 1, + xAccessorSecondId: 2, + yAccessorLeftFirstId: 5, + yAccessorLeftSecondId: 10, + yAccessorRightFirstId: 5, + yAccessorRightSecondId: 10, +}; + +const data: Datatable = { + type: 'datatable', + rows: [row], + columns: Object.keys(row).map((id) => ({ + id, + name: `Static value: ${row[id]}`, + meta: { + type: 'number', + params: { id: 'number' }, + }, + })), +}; + +function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerConfig[] { + return [ + { + layerId: 'first', + accessors: (yConfigs || []).map(({ forAccessor }) => forAccessor), + yConfig: yConfigs, + type: 'referenceLineLayer', + layerType: LayerTypes.REFERENCELINE, + table: data, + }, + ]; +} + +function createReferenceLine( + layerId: string, + lineLength: number, + args: ReferenceLineArgs +): ReferenceLineConfig { + return { + layerId, + type: 'referenceLine', + layerType: 'referenceLine', + lineLength, + yConfig: [{ type: 'referenceLineYConfig', ...args }], + }; +} + +interface YCoords { + y0: number | undefined; + y1: number | undefined; +} +interface XCoords { + x0: number | undefined; + x1: number | undefined; +} + +function getAxisFromId(layerPrefix: string): ExtendedYConfig['axisMode'] { + return /left/i.test(layerPrefix) ? 'left' : /right/i.test(layerPrefix) ? 'right' : 'bottom'; +} + +const emptyCoords = { x0: undefined, x1: undefined, y0: undefined, y1: undefined }; + +describe('ReferenceLines', () => { + describe('referenceLineLayers', () => { + let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + let defaultProps: Omit; + + beforeEach(() => { + formatters = { + left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + }; + + defaultProps = { + formatters, + isHorizontal: false, + axesMap: { left: true, right: false }, + paddingMap: {}, + }; + }); + + it.each([ + ['yAccessorLeft', 'above'], + ['yAccessorLeft', 'below'], + ['yAccessorRight', 'above'], + ['yAccessorRight', 'below'], + ] as Array<[string, Exclude]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const axisMode = getAxisFromId(layerPrefix); + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + + const y0 = fill === 'above' ? 5 : undefined; + const y1 = fill === 'above' ? undefined : 5; + + const referenceLineAnnotation = referenceLineLayer.find(ReferenceLineAnnotations).dive(); + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { x0: undefined, x1: undefined, y0, y1 }, + details: y0 ?? y1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above'], + ['xAccessor', 'below'], + ] as Array<[string, Exclude]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + + const x0 = fill === 'above' ? 1 : undefined; + const x1 = fill === 'above' ? undefined : 1; + + const referenceLineAnnotation = referenceLineLayer.find(ReferenceLineAnnotations).dive(); + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, x0, x1 }, + details: x0 ?? x1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['yAccessorLeft', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ] as Array<[string, Exclude, YCoords, YCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const axisMode = getAxisFromId(layerPrefix); + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.y0 ?? coordsA.y1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.y1 ?? coordsB.y0, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }], + ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }], + ] as Array<[string, Exclude, XCoords, XCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.x0 ?? coordsA.x1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.x1 ?? coordsB.x0, + header: undefined, + }, + ]) + ); + } + ); + + it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( + 'should let areas in different directions overlap: %s', + (layerPrefix) => { + const axisMode = getAxisFromId(layerPrefix); + + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x0: 1 } : { y0: 5 }) }, + details: axisMode === 'bottom' ? 1 : 5, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...(axisMode === 'bottom' ? { x1: 2 } : { y1: 10 }) }, + details: axisMode === 'bottom' ? 2 : 10, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], + ['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], + ] as Array<[Exclude, YCoords, YCoords]>)( + 'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s', + (fill, coordsA, coordsB) => { + const wrapper = shallow( + + ); + const referenceLineLayer = wrapper.find(ReferenceLineLayer).dive(); + const referenceLineAnnotations = referenceLineLayer.find(ReferenceLineAnnotations); + + expect( + referenceLineAnnotations.first().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: coordsA.y0 ?? coordsA.y1, + header: undefined, + }, + ]) + ); + expect( + referenceLineAnnotations.last().dive().find(RectAnnotation).prop('dataValues') + ).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: coordsB.y1 ?? coordsB.y0, + header: undefined, + }, + ]) + ); + } + ); + }); + + describe('referenceLines', () => { + let formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + let defaultProps: Omit; + + beforeEach(() => { + formatters = { + left: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + right: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + bottom: { convert: jest.fn((x) => x) } as unknown as FieldFormat, + }; + + defaultProps = { + formatters, + isHorizontal: false, + axesMap: { left: true, right: false }, + paddingMap: {}, + }; + }); + + it.each([ + ['yAccessorLeft', 'above'], + ['yAccessorLeft', 'below'], + ['yAccessorRight', 'above'], + ['yAccessorRight', 'below'], + ] as Array<[string, Exclude]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const axisMode = getAxisFromId(layerPrefix); + const value = 5; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + const y0 = fill === 'above' ? value : undefined; + const y1 = fill === 'above' ? undefined : value; + + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { x0: undefined, x1: undefined, y0, y1 }, + details: y0 ?? y1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above'], + ['xAccessor', 'below'], + ] as Array<[string, Exclude]>)( + 'should render a RectAnnotation for a reference line with fill set: %s %s', + (layerPrefix, fill) => { + const value = 1; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + const x0 = fill === 'above' ? value : undefined; + const x1 = fill === 'above' ? undefined : value; + + expect(referenceLineAnnotation.find(LineAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).exists()).toBe(true); + expect(referenceLineAnnotation.find(RectAnnotation).prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, x0, x1 }, + details: x0 ?? x1, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['yAccessorLeft', 'above', { y0: 10, y1: undefined }, { y0: 10, y1: undefined }], + ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: undefined, y1: 5 }], + ] as Array<[string, Exclude, YCoords, YCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const axisMode = getAxisFromId(layerPrefix); + const value = coordsA.y0 ?? coordsA.y1!; + const wrapper = shallow( + + ); + const referenceLine = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation = referenceLine.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: value, + header: undefined, + }, + ]) + ); + expect(referenceLineAnnotation.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: value, + header: undefined, + }, + ]) + ); + } + ); + + it.each([ + ['xAccessor', 'above', { x0: 1, x1: undefined }, { x0: 1, x1: undefined }], + ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: undefined, x1: 1 }], + ] as Array<[string, Exclude, XCoords, XCoords]>)( + 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', + (layerPrefix, fill, coordsA, coordsB) => { + const value = coordsA.x0 ?? coordsA.x1!; + const wrapper = shallow( + + ); + const referenceLine1 = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation1 = referenceLine1.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation1.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsA }, + details: value, + header: undefined, + }, + ]) + ); + + const referenceLine2 = wrapper.find(ReferenceLine).last().dive(); + const referenceLineAnnotation2 = referenceLine2.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation2.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { ...emptyCoords, ...coordsB }, + details: value, + header: undefined, + }, + ]) + ); + } + ); + + it.each(['yAccessorLeft', 'yAccessorRight', 'xAccessor'])( + 'should let areas in different directions overlap: %s', + (layerPrefix) => { + const axisMode = getAxisFromId(layerPrefix); + const value1 = 1; + const value2 = 10; + const wrapper = shallow( + + ); + const referenceLine1 = wrapper.find(ReferenceLine).first().dive(); + const referenceLineAnnotation1 = referenceLine1.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation1.find(RectAnnotation).first().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { + ...emptyCoords, + ...(axisMode === 'bottom' ? { x0: value1 } : { y0: value1 }), + }, + details: value1, + header: undefined, + }, + ]) + ); + + const referenceLine2 = wrapper.find(ReferenceLine).last().dive(); + const referenceLineAnnotation2 = referenceLine2.find(ReferenceLineAnnotations).dive(); + + expect(referenceLineAnnotation2.find(RectAnnotation).last().prop('dataValues')).toEqual( + expect.arrayContaining([ + { + coordinates: { + ...emptyCoords, + ...(axisMode === 'bottom' ? { x1: value2 } : { y1: value2 }), + }, + details: value2, + header: undefined, + }, + ]) + ); + } + ); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx new file mode 100644 index 00000000000000..5d48c3c05166d6 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import './reference_lines.scss'; + +import React from 'react'; +import { Position } from '@elastic/charts'; +import type { FieldFormat } from '@kbn/field-formats-plugin/common'; +import type { CommonXYReferenceLineLayerConfig, ReferenceLineConfig } from '../../../common/types'; +import { isReferenceLine } from '../../helpers'; +import { ReferenceLineLayer } from './reference_line_layer'; +import { ReferenceLine } from './reference_line'; +import { getNextValuesForReferenceLines } from './utils'; + +export interface ReferenceLinesProps { + layers: CommonXYReferenceLineLayerConfig[]; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; + paddingMap: Partial>; +} + +export const ReferenceLines = ({ layers, ...rest }: ReferenceLinesProps) => { + const referenceLines = layers.filter((layer): layer is ReferenceLineConfig => + isReferenceLine(layer) + ); + + const referenceLinesNextValues = getNextValuesForReferenceLines(referenceLines); + + return ( + <> + {layers.flatMap((layer) => { + if (!layer.yConfig) { + return null; + } + + const key = `referenceLine-${layer.layerId}`; + if (isReferenceLine(layer)) { + const nextValue = referenceLinesNextValues[layer.yConfig[0].fill][layer.layerId]; + return ; + } + + return ; + })} + + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx new file mode 100644 index 00000000000000..85d96c573f3142 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Position } from '@elastic/charts'; +import { euiLightVars } from '@kbn/ui-theme'; +import { FieldFormat } from '@kbn/field-formats-plugin/common'; +import { groupBy, orderBy } from 'lodash'; +import { IconPosition, ReferenceLineConfig, YAxisMode, FillStyle } from '../../../common/types'; +import { FillStyles } from '../../../common/constants'; +import { + LINES_MARKER_SIZE, + mapVerticalToHorizontalPlacement, + Marker, + MarkerBody, +} from '../../helpers'; +import { ReferenceLineAnnotationConfig } from './reference_line_annotations'; + +// if there's just one axis, put it on the other one +// otherwise use the same axis +// this function assume the chart is vertical +export function getBaseIconPlacement( + iconPosition: IconPosition | undefined, + axesMap?: Record, + axisMode?: YAxisMode +) { + if (iconPosition === 'auto') { + if (axisMode === 'bottom') { + return Position.Top; + } + if (axesMap) { + if (axisMode === 'left') { + return axesMap.right ? Position.Left : Position.Right; + } + return axesMap.left ? Position.Right : Position.Left; + } + } + + if (iconPosition === 'left') { + return Position.Left; + } + if (iconPosition === 'right') { + return Position.Right; + } + if (iconPosition === 'below') { + return Position.Bottom; + } + return Position.Top; +} + +export const getSharedStyle = (config: ReferenceLineAnnotationConfig) => ({ + strokeWidth: config.lineWidth || 1, + stroke: config.color || euiLightVars.euiColorDarkShade, + dash: + config.lineStyle === 'dashed' + ? [(config.lineWidth || 1) * 3, config.lineWidth || 1] + : config.lineStyle === 'dotted' + ? [config.lineWidth || 1, config.lineWidth || 1] + : undefined, +}); + +export const getLineAnnotationProps = ( + config: ReferenceLineAnnotationConfig, + labels: { markerLabel?: string; markerBodyLabel?: string }, + axesMap: Record<'left' | 'right', boolean>, + paddingMap: Partial>, + groupId: 'left' | 'right' | undefined, + isHorizontal: boolean +) => { + // get the position for vertical chart + const markerPositionVertical = getBaseIconPlacement( + config.iconPosition, + axesMap, + config.axisMode + ); + // the padding map is built for vertical chart + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + + return { + groupId, + marker: ( + + ), + markerBody: ( + + ), + // rotate the position if required + markerPosition: isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical, + }; +}; + +export const getGroupId = (axisMode: YAxisMode | undefined) => + axisMode === 'bottom' ? undefined : axisMode === 'right' ? 'right' : 'left'; + +export const getBottomRect = ( + headerLabel: string | undefined, + isFillAbove: boolean, + formatter: FieldFormat | undefined, + currentValue: number, + nextValue?: number +) => ({ + coordinates: { + x0: isFillAbove ? currentValue : nextValue, + y0: undefined, + x1: isFillAbove ? nextValue : currentValue, + y1: undefined, + }, + header: headerLabel, + details: formatter?.convert(currentValue) || currentValue.toString(), +}); + +export const getHorizontalRect = ( + headerLabel: string | undefined, + isFillAbove: boolean, + formatter: FieldFormat | undefined, + currentValue: number, + nextValue?: number +) => ({ + coordinates: { + x0: undefined, + y0: isFillAbove ? currentValue : nextValue, + x1: undefined, + y1: isFillAbove ? nextValue : currentValue, + }, + header: headerLabel, + details: formatter?.convert(currentValue) || currentValue.toString(), +}); + +const sortReferenceLinesByGroup = (referenceLines: ReferenceLineConfig[], group: FillStyle) => { + if (group === FillStyles.ABOVE || group === FillStyles.BELOW) { + const order = group === FillStyles.ABOVE ? 'asc' : 'desc'; + return orderBy(referenceLines, ({ yConfig: [{ value }] }) => value, [order]); + } + return referenceLines; +}; + +export const getNextValuesForReferenceLines = (referenceLines: ReferenceLineConfig[]) => { + const grouppedReferenceLines = groupBy(referenceLines, ({ yConfig: [yConfig] }) => yConfig.fill); + const groups = Object.keys(grouppedReferenceLines) as FillStyle[]; + + return groups.reduce>>( + (nextValueByDirection, group) => { + const sordedReferenceLines = sortReferenceLinesByGroup(grouppedReferenceLines[group], group); + + const nv = sordedReferenceLines.reduce>( + (nextValues, referenceLine, index, lines) => { + let nextValue: number | undefined; + if (index < lines.length - 1) { + const [yConfig] = lines[index + 1].yConfig; + nextValue = yConfig.value; + } + + return { ...nextValues, [referenceLine.layerId]: nextValue }; + }, + {} + ); + + return { ...nextValueByDirection, [group]: nv }; + }, + {} as Record> + ); +}; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index d03a5e648f3662..f46213fe41ba32 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -722,6 +722,75 @@ describe('XYChart component', () => { expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); }); + test('applies the line width to the chart', () => { + const { args } = sampleArgs(); + const lineWidthArg = { lineWidth: 10 }; + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + line: { strokeWidth: lineWidthArg.lineWidth }, + }); + + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + + test('applies showPoints to the chart', () => { + const checkIfPointsVisibilityIsApplied = (showPoints: boolean) => { + const { args } = sampleArgs(); + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + visible: showPoints, + }), + }); + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }; + + checkIfPointsVisibilityIsApplied(true); + checkIfPointsVisibilityIsApplied(false); + }); + + test('applies point radius to the chart', () => { + const pointsRadius = 10; + const { args } = sampleArgs(); + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + radius: pointsRadius, + }), + }); + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + test('it renders bar', () => { const { args } = sampleArgs(); const component = shallow( @@ -1967,17 +2036,10 @@ describe('XYChart component', () => { test('it should pass the formatter function to the axis', () => { const { args } = sampleArgs(); - const instance = shallow(); - - const tickFormatter = instance.find(Axis).first().prop('tickFormat'); - - if (!tickFormatter) { - throw new Error('tickFormatter prop not found'); - } - - tickFormatter('I'); + shallow(); - expect(convertSpy).toHaveBeenCalledWith('I'); + expect(convertSpy).toHaveBeenCalledWith(1652034840000); + expect(convertSpy).toHaveBeenCalledWith(1652122440000); }); test('it should set the tickLabel visibility on the x axis if the tick labels is hidden', () => { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 9bb3ea4f498e4f..7eceb72ecf75dd 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -42,14 +42,25 @@ import { LegendSizeToPixels, } from '@kbn/visualizations-plugin/common/constants'; import type { FilterEvent, BrushEvent, FormatFactory } from '../types'; -import type { CommonXYDataLayerConfig, SeriesType, XYChartProps } from '../../common/types'; +import { isTimeChart } from '../../common/helpers'; +import type { + CommonXYDataLayerConfig, + ExtendedYConfig, + ReferenceLineYConfig, + SeriesType, + XYChartProps, +} from '../../common/types'; import { isHorizontalChart, getAnnotationsLayers, getDataLayers, Series, getFormat, + isReferenceLineYConfig, getFormattedTablesByLayers, +} from '../helpers'; + +import { getFilteredLayers, getReferenceLayers, isDataLayer, @@ -60,7 +71,7 @@ import { } from '../helpers'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './legend_action'; -import { ReferenceLineAnnotations, computeChartMargins } from './reference_lines'; +import { ReferenceLines, computeChartMargins } from './reference_lines'; import { visualizationDefinitions } from '../definitions'; import { CommonXYLayerConfig } from '../../common/types'; import { SplitChart } from './split_chart'; @@ -71,8 +82,10 @@ import { OUTSIDE_RECT_ANNOTATION_WIDTH, OUTSIDE_RECT_ANNOTATION_WIDTH_SUGGESTION, } from './annotations'; -import { AxisExtentModes, SeriesTypes, ValueLabelModes } from '../../common/constants'; +import { AxisExtentModes, SeriesTypes, ValueLabelModes, XScaleTypes } from '../../common/constants'; import { DataLayers } from './data_layers'; +import { XYCurrentTime } from './xy_current_time'; + import './xy_chart.scss'; declare global { @@ -239,7 +252,10 @@ export function XYChart({ filteredBarLayers.some((layer) => layer.accessors.length > 1) || filteredBarLayers.some((layer) => isDataLayer(layer) && layer.splitAccessor); - const isTimeViz = Boolean(dataLayers.every((l) => l.xScaleType === 'time')); + const isTimeViz = isTimeChart(dataLayers); + + const defaultXScaleType = isTimeViz ? XScaleTypes.TIME : XScaleTypes.ORDINAL; + const isHistogramViz = dataLayers.every((l) => l.isHistogram); const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( @@ -270,6 +286,7 @@ export function XYChart({ }; const referenceLineLayers = getReferenceLayers(layers); + const annotationsLayers = getAnnotationsLayers(layers); const firstTable = dataLayers[0]?.table; @@ -286,7 +303,9 @@ export function XYChart({ const rangeAnnotations = getRangeAnnotations(annotationsLayers); const visualConfigs = [ - ...referenceLineLayers.flatMap(({ yConfig }) => yConfig), + ...referenceLineLayers.flatMap( + ({ yConfig }) => yConfig + ), ...groupedLineAnnotations, ].filter(Boolean); @@ -364,9 +383,10 @@ export function XYChart({ l.yConfig ? l.yConfig.map((yConfig) => ({ layerId: l.layerId, yConfig })) : [] ) .filter(({ yConfig }) => yConfig.axisMode === axis.groupId) - .map( - ({ layerId, yConfig }) => - `${layerId}-${yConfig.forAccessor}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` + .map(({ layerId, yConfig }) => + isReferenceLineYConfig(yConfig) + ? `${layerId}-${yConfig.value}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` + : `${layerId}-${yConfig.forAccessor}-${yConfig.fill !== 'none' ? 'rect' : 'line'}` ), }; }; @@ -590,6 +610,11 @@ export function XYChart({ ariaLabel={args.ariaLabel} ariaUseDefaultSummary={!args.ariaLabel} /> + )} {referenceLineLayers.length ? ( - = ({ enabled, isDarkMode, domain }) => { + if (!enabled) { + return null; + } + + const domainEnd = domain && 'max' in domain ? domain.max : undefined; + return ; +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx index 7ac661ed9709da..34e5e36091ae1a 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -8,6 +8,7 @@ import { AreaSeriesProps, + AreaSeriesStyle, BarSeriesProps, ColorVariant, LineSeriesProps, @@ -53,6 +54,7 @@ type GetSeriesPropsFn = (config: { emphasizeFitting?: boolean; fillOpacity?: number; formattedDatatableInfo: DatatableWithFormatInfo; + defaultXScaleType: XScaleType; }) => SeriesSpec; type GetSeriesNameFn = ( @@ -79,6 +81,14 @@ type GetColorFn = ( } ) => string | null; +type GetLineConfigFn = (config: { + xAccessor: string | undefined; + markSizeAccessor: string | undefined; + emphasizeFitting?: boolean; + showPoints?: boolean; + pointsRadius?: number; +}) => Partial; + export interface DatatableWithFormatInfo { table: Datatable; formattedColumns: Record; @@ -226,17 +236,26 @@ const getSeriesName: GetSeriesNameFn = ( return splitColumnId ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null; }; -const getPointConfig = ( - xAccessor: string | undefined, - markSizeAccessor: string | undefined, - emphasizeFitting?: boolean -) => ({ - visible: !xAccessor || markSizeAccessor !== undefined, - radius: xAccessor && !emphasizeFitting ? 5 : 0, +const getPointConfig: GetLineConfigFn = ({ + xAccessor, + markSizeAccessor, + emphasizeFitting, + showPoints, + pointsRadius, +}) => ({ + visible: showPoints !== undefined ? showPoints : !xAccessor || markSizeAccessor !== undefined, + radius: pointsRadius !== undefined ? pointsRadius : xAccessor && !emphasizeFitting ? 5 : 0, fill: markSizeAccessor ? ColorVariant.Series : undefined, }); -const getLineConfig = () => ({ visible: true, stroke: ColorVariant.Series, opacity: 1, dash: [] }); +const getFitLineConfig = () => ({ + visible: true, + stroke: ColorVariant.Series, + opacity: 1, + dash: [], +}); + +const getLineConfig = (strokeWidth?: number) => ({ strokeWidth }); const getColor: GetColorFn = ( { yAccessor, seriesKeys }, @@ -280,6 +299,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({ emphasizeFitting, fillOpacity, formattedDatatableInfo, + defaultXScaleType, }): SeriesSpec => { const { table, markSizeAccessor } = layer; const isStacked = layer.seriesType.includes('stacked'); @@ -342,7 +362,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({ markSizeAccessor: markSizeColumnId, markFormat: (value) => markFormatter.convert(value), data: rows, - xScaleType: xColumnId ? layer.xScaleType : 'ordinal', + xScaleType: xColumnId ? layer.xScaleType ?? defaultXScaleType : 'ordinal', yScaleType: formatter?.id === 'bytes' && yAxis?.scale === ScaleType.Linear ? ScaleType.LinearBinary @@ -361,15 +381,29 @@ export const getSeriesProps: GetSeriesPropsFn = ({ stackMode: isPercentage ? StackMode.Percentage : undefined, timeZone, areaSeriesStyle: { - point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), + point: getPointConfig({ + xAccessor: xColumnId, + markSizeAccessor: markSizeColumnId, + emphasizeFitting, + showPoints: layer.showPoints, + pointsRadius: layer.pointsRadius, + }), ...(fillOpacity && { area: { opacity: fillOpacity } }), ...(emphasizeFitting && { - fit: { area: { opacity: fillOpacity || 0.5 }, line: getLineConfig() }, + fit: { area: { opacity: fillOpacity || 0.5 }, line: getFitLineConfig() }, }), + line: getLineConfig(layer.lineWidth), }, lineSeriesStyle: { - point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), - ...(emphasizeFitting && { fit: { line: getLineConfig() } }), + point: getPointConfig({ + xAccessor: xColumnId, + markSizeAccessor: markSizeColumnId, + emphasizeFitting, + showPoints: layer.showPoints, + pointsRadius: layer.pointsRadius, + }), + ...(emphasizeFitting && { fit: { line: getFitLineConfig() } }), + line: getLineConfig(layer.lineWidth), }, name(d) { return getSeriesName(d, { diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts index a9f68ffc0a29bd..5c202bb6200a9c 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts @@ -9,13 +9,14 @@ import { search } from '@kbn/data-plugin/public'; import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { XYChartProps } from '../../common'; +import { isTimeChart } from '../../common/helpers'; import { getFilteredLayers } from './layers'; -import { isDataLayer } from './visualization'; +import { isDataLayer, getDataLayers } from './visualization'; export function calculateMinInterval({ args: { layers, minTimeBarInterval } }: XYChartProps) { const filteredLayers = getFilteredLayers(layers); if (filteredLayers.length === 0) return; - const isTimeViz = filteredLayers.every((l) => isDataLayer(l) && l.xScaleType === 'time'); + const isTimeViz = isTimeChart(getDataLayers(filteredLayers)); const xColumn = isDataLayer(filteredLayers[0]) && filteredLayers[0].xAccessor && diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/layers.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/layers.ts index 9934cc4f78fa9d..a30cc3a80ca443 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/layers.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/layers.ts @@ -12,13 +12,13 @@ import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/com import { CommonXYDataLayerConfig, CommonXYLayerConfig, - CommonXYReferenceLineLayerConfig, + ReferenceLineLayerConfig, } from '../../common/types'; import { isDataLayer, isReferenceLayer } from './visualization'; export function getFilteredLayers(layers: CommonXYLayerConfig[]) { - return layers.filter( - (layer): layer is CommonXYReferenceLineLayerConfig | CommonXYDataLayerConfig => { + return layers.filter( + (layer): layer is ReferenceLineLayerConfig | CommonXYDataLayerConfig => { let table: Datatable | undefined; let accessors: Array = []; let xAccessor: undefined | string | number; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts index e2f95491dbce8c..900cba47848538 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/state.ts @@ -7,7 +7,7 @@ */ import type { CommonXYLayerConfig, SeriesType, ExtendedYConfig, YConfig } from '../../common'; -import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization'; +import { getDataLayers, isAnnotationsLayer, isDataLayer, isReferenceLine } from './visualization'; export function isHorizontalSeries(seriesType: SeriesType) { return ( @@ -26,7 +26,11 @@ export function isHorizontalChart(layers: CommonXYLayerConfig[]) { } export const getSeriesColor = (layer: CommonXYLayerConfig, accessor: string) => { - if ((isDataLayer(layer) && layer.splitAccessor) || isAnnotationsLayer(layer)) { + if ( + (isDataLayer(layer) && layer.splitAccessor) || + isAnnotationsLayer(layer) || + isReferenceLine(layer) + ) { return null; } const yConfig: Array | undefined = layer?.yConfig; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts index db0b431d56fac0..480fa5374238ea 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/visualization.ts @@ -6,12 +6,21 @@ * Side Public License, v 1. */ -import { LayerTypes } from '../../common/constants'; +import { + LayerTypes, + REFERENCE_LINE, + REFERENCE_LINE_LAYER, + REFERENCE_LINE_Y_CONFIG, +} from '../../common/constants'; import { CommonXYLayerConfig, CommonXYDataLayerConfig, CommonXYReferenceLineLayerConfig, CommonXYAnnotationLayerConfig, + ReferenceLineLayerConfig, + ReferenceLineConfig, + ExtendedYConfigResult, + ReferenceLineYConfig, } from '../../common/types'; export const isDataLayer = (layer: CommonXYLayerConfig): layer is CommonXYDataLayerConfig => @@ -20,13 +29,24 @@ export const isDataLayer = (layer: CommonXYLayerConfig): layer is CommonXYDataLa export const getDataLayers = (layers: CommonXYLayerConfig[]) => (layers || []).filter((layer): layer is CommonXYDataLayerConfig => isDataLayer(layer)); -export const isReferenceLayer = ( +export const isReferenceLayer = (layer: CommonXYLayerConfig): layer is ReferenceLineLayerConfig => + layer.layerType === LayerTypes.REFERENCELINE && layer.type === REFERENCE_LINE_LAYER; + +export const isReferenceLine = (layer: CommonXYLayerConfig): layer is ReferenceLineConfig => + layer.type === REFERENCE_LINE; + +export const isReferenceLineYConfig = ( + yConfig: ReferenceLineYConfig | ExtendedYConfigResult +): yConfig is ReferenceLineYConfig => yConfig.type === REFERENCE_LINE_Y_CONFIG; + +export const isReferenceLineOrLayer = ( layer: CommonXYLayerConfig ): layer is CommonXYReferenceLineLayerConfig => layer.layerType === LayerTypes.REFERENCELINE; export const getReferenceLayers = (layers: CommonXYLayerConfig[]) => - (layers || []).filter((layer): layer is CommonXYReferenceLineLayerConfig => - isReferenceLayer(layer) + (layers || []).filter( + (layer): layer is CommonXYReferenceLineLayerConfig => + isReferenceLayer(layer) || isReferenceLine(layer) ); const isAnnotationLayerCommon = ( diff --git a/src/plugins/chart_expressions/expression_xy/public/plugin.ts b/src/plugins/chart_expressions/expression_xy/public/plugin.ts index 5c27da6b82b287..0dc6f62df3183d 100755 --- a/src/plugins/chart_expressions/expression_xy/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/public/plugin.ts @@ -24,8 +24,8 @@ import { gridlinesConfigFunction, axisExtentConfigFunction, tickLabelsConfigFunction, + referenceLineFunction, referenceLineLayerFunction, - extendedReferenceLineLayerFunction, annotationLayerFunction, labelsOrientationConfigFunction, axisTitlesVisibilityConfigFunction, @@ -64,8 +64,8 @@ export class ExpressionXyPlugin { expressions.registerFunction(annotationLayerFunction); expressions.registerFunction(extendedAnnotationLayerFunction); expressions.registerFunction(labelsOrientationConfigFunction); + expressions.registerFunction(referenceLineFunction); expressions.registerFunction(referenceLineLayerFunction); - expressions.registerFunction(extendedReferenceLineLayerFunction); expressions.registerFunction(axisTitlesVisibilityConfigFunction); expressions.registerFunction(xyVisFunction); expressions.registerFunction(layeredXyVisFunction); diff --git a/src/plugins/chart_expressions/expression_xy/server/plugin.ts b/src/plugins/chart_expressions/expression_xy/server/plugin.ts index cefde5d38a5f48..4ddac2b3a3f798 100755 --- a/src/plugins/chart_expressions/expression_xy/server/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/server/plugin.ts @@ -19,10 +19,10 @@ import { tickLabelsConfigFunction, annotationLayerFunction, labelsOrientationConfigFunction, - referenceLineLayerFunction, + referenceLineFunction, axisTitlesVisibilityConfigFunction, extendedDataLayerFunction, - extendedReferenceLineLayerFunction, + referenceLineLayerFunction, layeredXyVisFunction, extendedAnnotationLayerFunction, } from '../common/expression_functions'; @@ -42,8 +42,8 @@ export class ExpressionXyPlugin expressions.registerFunction(annotationLayerFunction); expressions.registerFunction(extendedAnnotationLayerFunction); expressions.registerFunction(labelsOrientationConfigFunction); + expressions.registerFunction(referenceLineFunction); expressions.registerFunction(referenceLineLayerFunction); - expressions.registerFunction(extendedReferenceLineLayerFunction); expressions.registerFunction(axisTitlesVisibilityConfigFunction); expressions.registerFunction(xyVisFunction); expressions.registerFunction(layeredXyVisFunction); diff --git a/src/plugins/controls/common/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts index 7dfdfab742d1ad..7ab1c3c4f67a01 100644 --- a/src/plugins/controls/common/control_types/options_list/types.ts +++ b/src/plugins/controls/common/control_types/options_list/types.ts @@ -17,7 +17,6 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; runPastTimeout?: boolean; - textFieldName?: string; singleSelect?: boolean; loading?: boolean; } diff --git a/src/plugins/controls/common/types.ts b/src/plugins/controls/common/types.ts index 4108e886e757dc..7d70f53c329338 100644 --- a/src/plugins/controls/common/types.ts +++ b/src/plugins/controls/common/types.ts @@ -30,5 +30,7 @@ export type ControlInput = EmbeddableInput & { export type DataControlInput = ControlInput & { fieldName: string; + parentFieldName?: string; + childFieldName?: string; dataViewId: string; }; diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 58ef91ed28173d..cb7b1b20018426 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -18,9 +18,14 @@ export const ControlGroupStrings = { defaultMessage: 'Controls', }), emptyState: { + getBadge: () => + i18n.translate('controls.controlGroup.emptyState.badgeText', { + defaultMessage: 'New', + }), getCallToAction: () => i18n.translate('controls.controlGroup.emptyState.callToAction', { - defaultMessage: 'Controls let you filter and interact with your dashboard data', + defaultMessage: + 'Filtering your data just got better with Controls, letting you display only the data you want to explore.', }), getAddControlButtonTitle: () => i18n.translate('controls.controlGroup.emptyState.addControlButtonTitle', { @@ -44,6 +49,14 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.editFlyoutTitle', { defaultMessage: 'Edit control', }), + getDataViewTitle: () => + i18n.translate('controls.controlGroup.manageControl.dataViewTitle', { + defaultMessage: 'Data view', + }), + getFieldTitle: () => + i18n.translate('controls.controlGroup.manageControl.fielditle', { + defaultMessage: 'Field', + }), getTitleInputTitle: () => i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { defaultMessage: 'Label', @@ -56,6 +69,10 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.widthInputTitle', { defaultMessage: 'Minimum width', }), + getControlSettingsTitle: () => + i18n.translate('controls.controlGroup.manageControl.controlSettingsTitle', { + defaultMessage: 'Additional settings', + }), getSaveChangesTitle: () => i18n.translate('controls.controlGroup.manageControl.saveChangesTitle', { defaultMessage: 'Save and close', @@ -64,6 +81,14 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.cancelTitle', { defaultMessage: 'Cancel', }), + getSelectFieldMessage: () => + i18n.translate('controls.controlGroup.manageControl.selectFieldMessage', { + defaultMessage: 'Please select a field', + }), + getSelectDataViewMessage: () => + i18n.translate('controls.controlGroup.manageControl.selectDataViewMessage', { + defaultMessage: 'Please select a data view', + }), getGrowSwitchTitle: () => i18n.translate('controls.controlGroup.manageControl.growSwitchTitle', { defaultMessage: 'Expand width to fit available space', diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index fdf99dc0f9c48d..4f52ef67ed7b17 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -14,7 +14,9 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import useMount from 'react-use/lib/useMount'; + import { EuiFlyoutHeader, EuiButtonGroup, @@ -29,32 +31,35 @@ import { EuiForm, EuiButtonEmpty, EuiSpacer, - EuiKeyPadMenu, - EuiKeyPadMenuItem, EuiIcon, - EuiToolTip, EuiSwitch, + EuiTextColor, } from '@elastic/eui'; +import { DataViewListItem, DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { IFieldSubTypeMulti } from '@kbn/es-query'; +import { + LazyDataViewPicker, + LazyFieldPicker, + withSuspense, +} from '@kbn/presentation-util-plugin/public'; -import { EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlEmbeddable, - ControlInput, ControlWidth, + DataControlFieldRegistry, DataControlInput, IEditableControlFactory, } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { pluginServices } from '../../services'; - interface EditControlProps { - embeddable?: ControlEmbeddable; + embeddable?: ControlEmbeddable; isCreate: boolean; title?: string; width: ControlWidth; + onSave: (type?: string) => void; grow: boolean; - onSave: (type: string) => void; onCancel: () => void; removeControl?: () => void; updateGrow?: (grow: boolean) => void; @@ -62,9 +67,18 @@ interface EditControlProps { updateWidth: (newWidth: ControlWidth) => void; getRelevantDataViewId?: () => string | undefined; setLastUsedDataViewId?: (newDataViewId: string) => void; - onTypeEditorChange: (partial: Partial) => void; + onTypeEditorChange: (partial: Partial) => void; } +interface ControlEditorState { + dataViewListItems: DataViewListItem[]; + selectedDataView?: DataView; + selectedField?: DataViewField; +} + +const FieldPicker = withSuspense(LazyFieldPicker, null); +const DataViewPicker = withSuspense(LazyDataViewPicker, null); + export const ControlEditor = ({ embeddable, isCreate, @@ -81,81 +95,104 @@ export const ControlEditor = ({ getRelevantDataViewId, setLastUsedDataViewId, }: EditControlProps) => { + const { dataViews } = pluginServices.getHooks(); + const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); + const { controls } = pluginServices.getServices(); const { getControlTypes, getControlFactory } = controls; + const [state, setState] = useState({ + dataViewListItems: [], + }); - const [selectedType, setSelectedType] = useState( - !isCreate && embeddable ? embeddable.type : getControlTypes()[0] - ); const [defaultTitle, setDefaultTitle] = useState(); const [currentTitle, setCurrentTitle] = useState(title); const [currentWidth, setCurrentWidth] = useState(width); const [currentGrow, setCurrentGrow] = useState(grow); const [controlEditorValid, setControlEditorValid] = useState(false); const [selectedField, setSelectedField] = useState( - embeddable - ? (embeddable.getInput() as DataControlInput).fieldName // CLEAN THIS ONCE OTHER PR GETS IN - : undefined + embeddable ? embeddable.getInput().fieldName : undefined ); - const getControlTypeEditor = (type: string) => { - const factory = getControlFactory(type); - const ControlTypeEditor = (factory as IEditableControlFactory).controlEditorComponent; - return ControlTypeEditor ? ( - { - if (!currentTitle || currentTitle === defaultTitle) { - setCurrentTitle(newDefaultTitle); - updateTitle(newDefaultTitle); - } - setDefaultTitle(newDefaultTitle); - }} - /> - ) : null; + const doubleLinkFields = (dataView: DataView) => { + // double link the parent-child relationship specifically for case-sensitivity support for options lists + const fieldRegistry: DataControlFieldRegistry = {}; + + for (const field of dataView.fields.getAll()) { + if (!fieldRegistry[field.name]) { + fieldRegistry[field.name] = { field, compatibleControlTypes: [] }; + } + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; + if (parentFieldName) { + fieldRegistry[field.name].parentFieldName = parentFieldName; + + const parentField = dataView.getFieldByName(parentFieldName); + if (!fieldRegistry[parentFieldName] && parentField) { + fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] }; + } + fieldRegistry[parentFieldName].childFieldName = field.name; + } + } + return fieldRegistry; }; - const getTypeButtons = () => { - return getControlTypes().map((type) => { - const factory = getControlFactory(type); - const icon = (factory as EmbeddableFactoryDefinition).getIconType?.(); - const tooltip = (factory as EmbeddableFactoryDefinition).getDescription?.(); - const menuPadItem = ( - { - setSelectedType(type); - if (!isCreate) - setSelectedField( - embeddable && type === embeddable.type - ? (embeddable.getInput() as DataControlInput).fieldName - : undefined - ); - }} - > - - - ); + const fieldRegistry = useMemo(() => { + if (!state.selectedDataView) return; + const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(state.selectedDataView); - return tooltip ? ( - - {menuPadItem} - - ) : ( - menuPadItem - ); + const controlFactories = getControlTypes().map( + (controlType) => getControlFactory(controlType) as IEditableControlFactory + ); + state.selectedDataView.fields.map((dataViewField) => { + for (const factory of controlFactories) { + if (factory.isFieldCompatible) { + factory.isFieldCompatible(newFieldRegistry[dataViewField.name]); + } + } + + if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) { + delete newFieldRegistry[dataViewField.name]; + } }); - }; + return newFieldRegistry; + }, [state.selectedDataView, getControlFactory, getControlTypes]); + + useMount(() => { + let mounted = true; + if (selectedField) setDefaultTitle(selectedField); + + (async () => { + const dataViewListItems = await getIdsWithTitle(); + const initialId = + embeddable?.getInput().dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); + let dataView: DataView | undefined; + if (initialId) { + onTypeEditorChange({ dataViewId: initialId }); + dataView = await get(initialId); + } + if (!mounted) return; + setState((s) => ({ + ...s, + selectedDataView: dataView, + dataViewListItems, + })); + })(); + return () => { + mounted = false; + }; + }); + + useEffect( + () => setControlEditorValid(Boolean(selectedField) && Boolean(state.selectedDataView)), + [selectedField, setControlEditorValid, state.selectedDataView] + ); + + const { selectedDataView: dataView } = state; + const controlType = + selectedField && fieldRegistry && fieldRegistry[selectedField].compatibleControlTypes[0]; + const factory = controlType && getControlFactory(controlType); + const CustomSettings = + factory && (factory as IEditableControlFactory).controlEditorOptionsComponent; return ( <> @@ -169,64 +206,124 @@ export const ControlEditor = ({ + + { + setLastUsedDataViewId?.(dataViewId); + if (dataViewId === dataView?.id) return; + + onTypeEditorChange({ dataViewId }); + setSelectedField(undefined); + get(dataViewId).then((newDataView) => { + setState((s) => ({ ...s, selectedDataView: newDataView })); + }); + }} + trigger={{ + label: + state.selectedDataView?.title ?? + ControlGroupStrings.manageControl.getSelectDataViewMessage(), + }} + /> + + + { + return Boolean(fieldRegistry?.[field.name]); + }} + selectedFieldName={selectedField} + dataView={dataView} + onSelectField={(field) => { + onTypeEditorChange({ + fieldName: field.name, + parentFieldName: fieldRegistry?.[field.name].parentFieldName, + childFieldName: fieldRegistry?.[field.name].childFieldName, + }); + + const newDefaultTitle = field.displayName ?? field.name; + setDefaultTitle(newDefaultTitle); + setSelectedField(field.name); + if (!currentTitle || currentTitle === defaultTitle) { + setCurrentTitle(newDefaultTitle); + updateTitle(newDefaultTitle); + } + }} + /> + - {getTypeButtons()} + {factory ? ( + + + + + + {factory.getDisplayName()} + + + ) : ( + + {ControlGroupStrings.manageControl.getSelectFieldMessage()} + + )} + + + { + updateTitle(e.target.value || defaultTitle); + setCurrentTitle(e.target.value); + }} + /> - {selectedType && ( + + { + setCurrentWidth(newWidth as ControlWidth); + updateWidth(newWidth as ControlWidth); + }} + /> + + {updateGrow ? ( + + { + setCurrentGrow(!currentGrow); + updateGrow(!currentGrow); + }} + data-test-subj="control-editor-grow-switch" + /> + + ) : null} + {CustomSettings && (factory as IEditableControlFactory).controlEditorOptionsComponent && ( + + + + )} + {removeControl && ( <> - {getControlTypeEditor(selectedType)} - - { - updateTitle(e.target.value || defaultTitle); - setCurrentTitle(e.target.value); - }} - /> - - - { - setCurrentWidth(newWidth as ControlWidth); - updateWidth(newWidth as ControlWidth); - }} - /> - - {updateGrow ? ( - - { - setCurrentGrow(!currentGrow); - updateGrow(!currentGrow); - }} - data-test-subj="control-editor-grow-switch" - /> - - ) : null} - {removeControl && ( - { - onCancel(); - removeControl(); - }} - > - {ControlGroupStrings.management.getDeleteButtonTitle()} - - )} + { + onCancel(); + removeControl(); + }} + > + {ControlGroupStrings.management.getDeleteButtonTitle()} + )} @@ -250,7 +347,7 @@ export const ControlEditor = ({ iconType="check" color="primary" disabled={!controlEditorValid} - onClick={() => onSave(selectedType)} + onClick={() => onSave(controlType)} > {ControlGroupStrings.manageControl.getSaveChangesTitle()}
diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 2f791ac74d3ae8..a3da7071d7ceb1 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -14,7 +14,7 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { ControlGroupStrings } from '../control_group_strings'; -import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; +import { ControlWidth, ControlInput, IEditableControlFactory, DataControlInput } from '../../types'; import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_GROW, @@ -59,7 +59,7 @@ export const CreateControlButton = ({ const PresentationUtilProvider = pluginServices.getContextProvider(); const initialInputPromise = new Promise((resolve, reject) => { - let inputToReturn: Partial = {}; + let inputToReturn: Partial = {}; const onCancel = (ref: OverlayRef) => { if (Object.keys(inputToReturn).length === 0) { @@ -80,6 +80,21 @@ export const CreateControlButton = ({ }); }; + const onSave = (ref: OverlayRef, type?: string) => { + if (!type) { + reject(); + ref.close(); + return; + } + + const factory = getControlFactory(type) as IEditableControlFactory; + if (factory.presaveTransformFunction) { + inputToReturn = factory.presaveTransformFunction(inputToReturn); + } + resolve({ type, controlInput: inputToReturn }); + ref.close(); + }; + const flyoutInstance = openFlyout( toMountPoint( @@ -92,14 +107,7 @@ export const CreateControlButton = ({ updateTitle={(newTitle) => (inputToReturn.title = newTitle)} updateWidth={updateDefaultWidth} updateGrow={updateDefaultGrow} - onSave={(type: string) => { - const factory = getControlFactory(type) as IEditableControlFactory; - if (factory.presaveTransformFunction) { - inputToReturn = factory.presaveTransformFunction(inputToReturn); - } - resolve({ type, controlInput: inputToReturn }); - flyoutInstance.close(); - }} + onSave={(type) => onSave(flyoutInstance, type)} onCancel={() => onCancel(flyoutInstance)} onTypeEditorChange={(partialInput) => (inputToReturn = { ...inputToReturn, ...partialInput }) diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index b3fa8834da5e0b..370b4f7caa0110 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -11,14 +11,19 @@ import { EuiButtonIcon } from '@elastic/eui'; import React, { useEffect, useRef } from 'react'; import { OverlayRef } from '@kbn/core/public'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public'; import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; import { ControlGroupInput } from '../types'; import { ControlEditor } from './control_editor'; import { pluginServices } from '../../services'; -import { forwardAllContext } from './forward_all_context'; import { ControlGroupStrings } from '../control_group_strings'; -import { IEditableControlFactory, ControlInput } from '../../types'; +import { + IEditableControlFactory, + ControlInput, + DataControlInput, + ControlEmbeddable, +} from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container'; @@ -56,15 +61,19 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }, [panels, embeddableId]); const editControl = async () => { - const panel = panels[embeddableId]; - let factory = getControlFactory(panel.type); - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); - - const embeddable = await untilEmbeddableLoaded(embeddableId); - const controlGroup = embeddable.getRoot() as ControlGroupContainer; + const PresentationUtilProvider = pluginServices.getContextProvider(); + const embeddable = (await untilEmbeddableLoaded( + embeddableId + )) as ControlEmbeddable; const initialInputPromise = new Promise((resolve, reject) => { - let inputToReturn: Partial = {}; + const panel = panels[embeddableId]; + let factory = getControlFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + + const controlGroup = embeddable.getRoot() as ControlGroupContainer; + + let inputToReturn: Partial = {}; let removed = false; const onCancel = (ref: OverlayRef) => { @@ -94,7 +103,13 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }); }; - const onSave = (type: string, ref: OverlayRef) => { + const onSave = (ref: OverlayRef, type?: string) => { + if (!type) { + reject(); + ref.close(); + return; + } + // if the control now has a new type, need to replace the old factory with // one of the correct new type if (latestPanelState.current.type !== type) { @@ -110,44 +125,47 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }; const flyoutInstance = openFlyout( - forwardAllContext( - onCancel(flyoutInstance)} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} - updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} - updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} - onTypeEditorChange={(partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }} - onSave={(type) => onSave(type, flyoutInstance)} - removeControl={() => { - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - removeEmbeddable(embeddableId); - removed = true; - flyoutInstance.close(); - } - }); - }} - />, - reduxContainerContext + toMountPoint( + + onCancel(flyoutInstance)} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} + updateWidth={(newWidth) => + dispatch(setControlWidth({ width: newWidth, embeddableId })) + } + updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} + onTypeEditorChange={(partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }} + onSave={(type) => onSave(flyoutInstance, type)} + removeControl={() => { + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + removed = true; + flyoutInstance.close(); + } + }); + }} + /> + ), { outsideClickCloses: false, onClose: (flyout) => { - setFlyoutRef(undefined); onCancel(flyout); + setFlyoutRef(undefined); }, } ); diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx deleted file mode 100644 index b6d5a0877d7ce8..00000000000000 --- a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; - -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { IFieldSubTypeMulti } from '@kbn/es-query'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; - -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { OptionsListStrings } from './options_list_strings'; -import { OptionsListEmbeddableInput, OptionsListField } from './types'; -interface OptionsListEditorState { - singleSelect?: boolean; - runPastTimeout?: boolean; - dataViewListItems: DataViewListItem[]; - fieldsMap?: { [key: string]: OptionsListField }; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const OptionsListEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - singleSelect: initialInput?.singleSelect, - runPastTimeout: initialInput?.runPastTimeout, - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems, fieldsMap: {} })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect(() => { - if (!state.dataView) return; - - // double link the parent-child relationship so that we can filter in fields which are multi-typed to text / keyword - const doubleLinkedFields: OptionsListField[] = state.dataView?.fields.getAll(); - for (const field of doubleLinkedFields) { - const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; - if (parentFieldName) { - (field as OptionsListField).parentFieldName = parentFieldName; - const parentField = state.dataView?.getFieldByName(parentFieldName); - (parentField as OptionsListField).childFieldName = field.name; - } - } - - const newFieldsMap: OptionsListEditorState['fieldsMap'] = {}; - for (const field of doubleLinkedFields) { - if (field.type === 'boolean') { - newFieldsMap[field.name] = field; - } - - // field type is keyword, check if this field is related to a text mapped field and include it. - else if (field.aggregatable && field.type === 'string') { - const childField = - (field.childFieldName && state.dataView?.fields.getByName(field.childFieldName)) || - undefined; - const parentField = - (field.parentFieldName && state.dataView?.fields.getByName(field.parentFieldName)) || - undefined; - - const textFieldName = childField?.esTypes?.includes('text') - ? childField.name - : parentField?.esTypes?.includes('text') - ? parentField.name - : undefined; - - newFieldsMap[field.name] = { ...field, textFieldName } as OptionsListField; - } - } - setState((s) => ({ ...s, fieldsMap: newFieldsMap })); - }, [state.dataView]); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? OptionsListStrings.editor.getNoDataViewTitle(), - }} - /> - - - Boolean(state.fieldsMap?.[field.name])} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - const textFieldName = state.fieldsMap?.[field.name].textFieldName; - onChange({ - fieldName: field.name, - textFieldName, - }); - setSelectedField(field.name); - }} - /> - - - { - onChange({ singleSelect: !state.singleSelect }); - setState((s) => ({ ...s, singleSelect: !s.singleSelect })); - }} - /> - - - { - onChange({ runPastTimeout: !state.runPastTimeout }); - setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx new file mode 100644 index 00000000000000..e09d1887aac1f3 --- /dev/null +++ b/src/plugins/controls/public/control_types/options_list/options_list_editor_options.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +import { OptionsListEmbeddableInput } from './types'; +import { OptionsListStrings } from './options_list_strings'; +import { ControlEditorProps } from '../..'; + +interface OptionsListEditorState { + singleSelect?: boolean; + runPastTimeout?: boolean; +} + +export const OptionsListEditorOptions = ({ + initialInput, + onChange, +}: ControlEditorProps) => { + const [state, setState] = useState({ + singleSelect: initialInput?.singleSelect, + runPastTimeout: initialInput?.runPastTimeout, + }); + + return ( + <> + + { + onChange({ singleSelect: !state.singleSelect }); + setState((s) => ({ ...s, singleSelect: !s.singleSelect })); + }} + /> + + + { + onChange({ runPastTimeout: !state.runPastTimeout }); + setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); + }} + /> + + + ); +}; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index edf4cb6ddaff17..0376776121eea0 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -179,7 +179,8 @@ export class OptionsListEmbeddable extends Embeddable => { - const { dataViewId, fieldName, textFieldName } = this.getInput(); + const { dataViewId, fieldName, parentFieldName, childFieldName } = this.getInput(); + if (!this.dataView || this.dataView.id !== dataViewId) { this.dataView = await this.dataViewsService.get(dataViewId); if (this.dataView === undefined) { @@ -192,6 +193,16 @@ export class OptionsListEmbeddable extends Embeddable { + if ( + (dataControlField.field.aggregatable && dataControlField.field.type === 'string') || + dataControlField.field.type === 'boolean' + ) { + dataControlField.compatibleControlTypes.push(this.type); + } + }; + + public controlEditorOptionsComponent = OptionsListEditorOptions; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx deleted file mode 100644 index 13f688c5dd3182..00000000000000 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; -import { EuiFormRow } from '@elastic/eui'; - -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { RangeSliderEmbeddableInput } from './types'; -import { RangeSliderStrings } from './range_slider_strings'; - -interface RangeSliderEditorState { - dataViewListItems: DataViewListItem[]; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const RangeSliderEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? RangeSliderStrings.editor.getNoDataViewTitle(), - }} - /> - - - field.aggregatable && field.type === 'number'} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setSelectedField(field.name); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx index bd8b8a394988b7..962937a8dc500d 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx @@ -9,8 +9,7 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; -import { RangeSliderEditor } from './range_slider_editor'; -import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; import { createRangeSliderExtract, @@ -46,7 +45,11 @@ export class RangeSliderEmbeddableFactory return newInput; }; - public controlEditorComponent = RangeSliderEditor; + public isFieldCompatible = (dataControlField: DataControlField) => { + if (dataControlField.field.aggregatable && dataControlField.field.type === 'number') { + dataControlField.compatibleControlTypes.push(this.type); + } + }; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx deleted file mode 100644 index d8f130661983f6..00000000000000 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_editor.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useEffect, useState } from 'react'; -import { EuiFormRow } from '@elastic/eui'; - -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; -import { - LazyDataViewPicker, - LazyFieldPicker, - withSuspense, -} from '@kbn/presentation-util-plugin/public'; -import { pluginServices } from '../../services'; -import { ControlEditorProps } from '../../types'; -import { TimeSliderStrings } from './time_slider_strings'; - -interface TimeSliderEditorState { - dataViewListItems: DataViewListItem[]; - dataView?: DataView; -} - -const FieldPicker = withSuspense(LazyFieldPicker, null); -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -export const TimeSliderEditor = ({ - onChange, - initialInput, - setValidState, - setDefaultTitle, - getRelevantDataViewId, - setLastUsedDataViewId, - selectedField, - setSelectedField, -}: ControlEditorProps) => { - // Controls Services Context - const { dataViews } = pluginServices.getHooks(); - const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); - - const [state, setState] = useState({ - dataViewListItems: [], - }); - - useMount(() => { - let mounted = true; - if (selectedField) setDefaultTitle(selectedField); - (async () => { - const dataViewListItems = await getIdsWithTitle(); - const initialId = - initialInput?.dataViewId ?? getRelevantDataViewId?.() ?? (await getDefaultId()); - let dataView: DataView | undefined; - if (initialId) { - onChange({ dataViewId: initialId }); - dataView = await get(initialId); - } - if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); - })(); - return () => { - mounted = false; - }; - }); - - useEffect( - () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), - [selectedField, setValidState, state.dataView] - ); - - const { dataView } = state; - return ( - <> - - { - setLastUsedDataViewId?.(dataViewId); - if (dataViewId === dataView?.id) return; - - onChange({ dataViewId }); - setSelectedField(undefined); - get(dataViewId).then((newDataView) => { - setState((s) => ({ ...s, dataView: newDataView })); - }); - }} - trigger={{ - label: state.dataView?.title ?? TimeSliderStrings.editor.getNoDataViewTitle(), - }} - /> - - - field.type === 'date'} - selectedFieldName={selectedField} - dataView={dataView} - onSelectField={(field) => { - setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setSelectedField(field.name); - }} - /> - - - ); -}; diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx index a49a0b85818f22..6fad0139b98e29 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable_factory.tsx @@ -10,12 +10,11 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; import { TIME_SLIDER_CONTROL } from '../..'; -import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { createOptionsListExtract, createOptionsListInject, } from '../../../common/control_types/options_list/options_list_persistable_state'; -import { TimeSliderEditor } from './time_slider_editor'; import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; import { TimeSliderStrings } from './time_slider_strings'; @@ -48,7 +47,11 @@ export class TimesliderEmbeddableFactory return newInput; }; - public controlEditorComponent = TimeSliderEditor; + public isFieldCompatible = (dataControlField: DataControlField) => { + if (dataControlField.field.type === 'date') { + dataControlField.compatibleControlTypes.push(this.type); + } + }; public isEditable = () => Promise.resolve(false); diff --git a/src/plugins/controls/public/controls_callout/controls_callout.scss b/src/plugins/controls/public/controls_callout/controls_callout.scss index e0f7e1481d156f..74add651a52371 100644 --- a/src/plugins/controls/public/controls_callout/controls_callout.scss +++ b/src/plugins/controls/public/controls_callout/controls_callout.scss @@ -1,5 +1,5 @@ @include euiBreakpoint('xs', 's') { - .controlsIllustration { + .controlsIllustration, .emptyStateBadge { display: none; } } @@ -15,14 +15,15 @@ } @include euiBreakpoint('m', 'l', 'xl') { - height: $euiSize * 4; + height: $euiSizeS * 6; - .emptyStateText { + .emptyStateBadge { padding-left: $euiSize * 2; + text-transform: uppercase; } } @include euiBreakpoint('xs', 's') { - min-height: $euiSize * 4; + min-height: $euiSizeS * 6; .emptyStateText { padding-left: 0; diff --git a/src/plugins/controls/public/controls_callout/controls_callout.tsx b/src/plugins/controls/public/controls_callout/controls_callout.tsx index 708b224187e1c2..b207657cc02880 100644 --- a/src/plugins/controls/public/controls_callout/controls_callout.tsx +++ b/src/plugins/controls/public/controls_callout/controls_callout.tsx @@ -6,7 +6,14 @@ * Side Public License, v 1. */ -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonEmpty, EuiPanel } from '@elastic/eui'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButtonEmpty, + EuiPanel, +} from '@elastic/eui'; import React from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; @@ -39,6 +46,9 @@ export const ControlsCallout = ({ getCreateControlButton }: CalloutProps) => { + + {ControlGroupStrings.emptyState.getBadge()} +

{ControlGroupStrings.emptyState.getCallToAction()}

diff --git a/src/plugins/controls/public/controls_callout/controls_illustration.tsx b/src/plugins/controls/public/controls_callout/controls_illustration.tsx index 925dd90fc87007..39d96ee8ad8577 100644 --- a/src/plugins/controls/public/controls_callout/controls_illustration.tsx +++ b/src/plugins/controls/public/controls_callout/controls_illustration.tsx @@ -11,8 +11,8 @@ import React from 'react'; export const ControlsIllustration = () => ( ( fill="#FCC316" d="M67.873 63.635l-2.678 4.641-2.678-4.64-2.678-4.642H70.55l-2.678 4.641z" /> - - - - , factory: EmbeddableFactory ) { - (factory as IEditableControlFactory).controlEditorComponent = - factoryDef.controlEditorComponent; + (factory as IEditableControlFactory).controlEditorOptionsComponent = + factoryDef.controlEditorOptionsComponent ?? undefined; (factory as IEditableControlFactory).presaveTransformFunction = factoryDef.presaveTransformFunction; + (factory as IEditableControlFactory).isFieldCompatible = factoryDef.isFieldCompatible; } public setup( diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index 4ab4db2eec0373..71436fa9926e0e 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -16,7 +16,7 @@ import { IEmbeddable, } from '@kbn/embeddable-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataView, DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { ControlInput } from '../common/types'; import { ControlsService } from './services/controls'; @@ -28,7 +28,11 @@ export interface CommonControlOutput { export type ControlOutput = EmbeddableOutput & CommonControlOutput; -export type ControlFactory = EmbeddableFactory; +export type ControlFactory = EmbeddableFactory< + ControlInput, + ControlOutput, + ControlEmbeddable +>; export type ControlEmbeddable< TControlEmbeddableInput extends ControlInput = ControlInput, @@ -39,21 +43,28 @@ export type ControlEmbeddable< * Control embeddable editor types */ export interface IEditableControlFactory { - controlEditorComponent?: (props: ControlEditorProps) => JSX.Element; + controlEditorOptionsComponent?: (props: ControlEditorProps) => JSX.Element; presaveTransformFunction?: ( newState: Partial, embeddable?: ControlEmbeddable ) => Partial; + isFieldCompatible?: (dataControlField: DataControlField) => void; // reducer } + export interface ControlEditorProps { initialInput?: Partial; - getRelevantDataViewId?: () => string | undefined; - setLastUsedDataViewId?: (newId: string) => void; onChange: (partial: Partial) => void; - setValidState: (valid: boolean) => void; - setDefaultTitle: (defaultTitle: string) => void; - selectedField: string | undefined; - setSelectedField: (newField: string | undefined) => void; +} + +export interface DataControlField { + field: DataViewField; + parentFieldName?: string; + childFieldName?: string; + compatibleControlTypes: string[]; +} + +export interface DataControlFieldRegistry { + [fieldName: string]: DataControlField; } /** diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts index ff64f4672922c5..94c9d996499c37 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts @@ -20,7 +20,7 @@ import { Filter, Query, waitUntilNextSessionCompletes$, - QueryState, + GlobalQueryStateFromUrl, } from '../../services/data'; import { cleanFiltersForSerialize } from '.'; @@ -166,7 +166,7 @@ export const applyDashboardFilterState = ({ * time range and refresh interval to the query service. */ if (currentDashboardState.timeRestore) { - const globalQueryState = kbnUrlStateStorage.get('_g'); + const globalQueryState = kbnUrlStateStorage.get('_g'); if (!globalQueryState?.time) { if (savedDashboard.timeFrom && savedDashboard.timeTo) { timefilterService.setTime({ diff --git a/src/plugins/dashboard/public/locator.ts b/src/plugins/dashboard/public/locator.ts index 9c187ca0803cf9..7649343e5bf6ed 100644 --- a/src/plugins/dashboard/public/locator.ts +++ b/src/plugins/dashboard/public/locator.ts @@ -9,7 +9,12 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { flow } from 'lodash'; import { type Filter } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public'; @@ -155,7 +160,7 @@ export class DashboardAppLocatorDefinition implements LocatorDefinition( + path = setStateToKbnUrl( '_g', cleanEmptyKeys({ time: params.timeRange, diff --git a/src/plugins/dashboard/public/services/saved_object_loader.ts b/src/plugins/dashboard/public/services/saved_object_loader.ts index 3c406357c02941..780daa2939aa43 100644 --- a/src/plugins/dashboard/public/services/saved_object_loader.ts +++ b/src/plugins/dashboard/public/services/saved_object_loader.ts @@ -98,12 +98,16 @@ export class SavedObjectLoader { mapHitSource( source: Record, id: string, - references: SavedObjectReference[] = [] - ) { - source.id = id; - source.url = this.urlFor(id); - source.references = references; - return source; + references: SavedObjectReference[] = [], + updatedAt?: string + ): Record { + return { + ...source, + id, + url: this.urlFor(id), + references, + updatedAt, + }; } /** @@ -116,12 +120,14 @@ export class SavedObjectLoader { attributes, id, references = [], + updatedAt, }: { attributes: Record; id: string; references?: SavedObjectReference[]; + updatedAt?: string; }) { - return this.mapHitSource(attributes, id, references); + return this.mapHitSource(attributes, id, references, updatedAt); } /** diff --git a/src/plugins/data/README.mdx b/src/plugins/data/README.mdx index e24a949a0c2ecb..a8cb06ff9e60b3 100644 --- a/src/plugins/data/README.mdx +++ b/src/plugins/data/README.mdx @@ -91,6 +91,8 @@ function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) { ``` +You can also retrieve a snapshot of the whole `QueryState` by using `data.query.getState()` + ### Timefilter `data.query.timefilter` is responsible for the time range filter and the auto refresh behavior settings. diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 90169ca552ac24..0f50384893b184 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -255,6 +255,7 @@ export { createSavedQueryService, connectToQueryState, syncQueryStateWithUrl, + syncGlobalQueryStateWithUrl, getDefaultQuery, FilterManager, TimeHistory, @@ -280,6 +281,7 @@ export type { QueryStringContract, QuerySetup, TimefilterSetup, + GlobalQueryStateFromUrl, } from './query'; export type { AggsStart } from './search/aggs'; diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 27e365ce0cb371..e1b42b7c193e2a 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -38,6 +38,7 @@ const createStartContract = (): Start => { }), get: jest.fn().mockReturnValue(Promise.resolve({})), clearCache: jest.fn(), + getIdsWithTitle: jest.fn(), } as unknown as DataViewsContract; return { diff --git a/src/plugins/data/public/query/index.tsx b/src/plugins/data/public/query/index.tsx index f426573e1bd6cd..392b8fda144176 100644 --- a/src/plugins/data/public/query/index.tsx +++ b/src/plugins/data/public/query/index.tsx @@ -15,3 +15,4 @@ export * from './saved_query'; export * from './persisted_log'; export * from './state_sync'; export type { QueryStringContract } from './query_string'; +export type { QueryState } from './query_state'; diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index 2ab15aab26db64..296a61afef2fd9 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -21,6 +21,7 @@ const createSetupContractMock = () => { timefilter: timefilterServiceMock.createSetupContract(), queryString: queryStringManagerMock.createSetupContract(), state$: new Observable(), + getState: jest.fn(), }; return setupContract; @@ -31,8 +32,9 @@ const createStartContractMock = () => { addToQueryLog: jest.fn(), filterManager: createFilterManagerMock(), queryString: queryStringManagerMock.createStartContract(), - savedQueries: jest.fn() as any, + savedQueries: { getSavedQuery: jest.fn() } as any, state$: new Observable(), + getState: jest.fn(), timefilter: timefilterServiceMock.createStartContract(), getEsQuery: jest.fn(), }; diff --git a/src/plugins/data/public/query/query_service.test.ts b/src/plugins/data/public/query/query_service.test.ts new file mode 100644 index 00000000000000..5eb6815c3ba201 --- /dev/null +++ b/src/plugins/data/public/query/query_service.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FilterStateStore } from '@kbn/es-query'; +import { FilterManager } from './filter_manager'; +import { QueryStringContract } from './query_string'; +import { getFilter } from './filter_manager/test_helpers/get_stub_filter'; +import { UI_SETTINGS } from '../../common'; +import { coreMock } from '@kbn/core/public/mocks'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { QueryService, QueryStart } from './query_service'; +import { StubBrowserStorage } from '@kbn/test-jest-helpers'; +import { TimefilterContract } from './timefilter'; +import { createNowProviderMock } from '../now_provider/mocks'; + +const setupMock = coreMock.createSetup(); +const startMock = coreMock.createStart(); + +setupMock.uiSettings.get.mockImplementation((key: string) => { + switch (key) { + case UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT: + return true; + case UI_SETTINGS.SEARCH_QUERY_LANGUAGE: + return 'kuery'; + case UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS: + return { from: 'now-15m', to: 'now' }; + case UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: + return { pause: false, value: 0 }; + default: + throw new Error(`query_service test: not mocked uiSetting: ${key}`); + } +}); + +describe('query_service', () => { + let queryServiceStart: QueryStart; + let filterManager: FilterManager; + let timeFilter: TimefilterContract; + let queryStringManager: QueryStringContract; + + beforeEach(() => { + const queryService = new QueryService(); + queryService.setup({ + uiSettings: setupMock.uiSettings, + storage: new Storage(new StubBrowserStorage()), + nowProvider: createNowProviderMock(), + }); + queryServiceStart = queryService.start({ + uiSettings: setupMock.uiSettings, + storage: new Storage(new StubBrowserStorage()), + http: startMock.http, + }); + filterManager = queryServiceStart.filterManager; + timeFilter = queryServiceStart.timefilter.timefilter; + queryStringManager = queryServiceStart.queryString; + }); + + test('state is initialized with state from query service', () => { + const state = queryServiceStart.getState(); + + expect(state).toEqual({ + filters: filterManager.getFilters(), + refreshInterval: timeFilter.getRefreshInterval(), + time: timeFilter.getTime(), + query: queryStringManager.getQuery(), + }); + }); + + test('state is updated when underlying state in service updates', () => { + const filters = [getFilter(FilterStateStore.GLOBAL_STATE, true, true, 'key1', 'value1')]; + const query = { language: 'kql', query: 'query' }; + const time = { from: new Date().toISOString(), to: new Date().toISOString() }; + const refreshInterval = { pause: false, value: 10 }; + + filterManager.setFilters(filters); + queryStringManager.setQuery(query); + timeFilter.setTime(time); + timeFilter.setRefreshInterval(refreshInterval); + + expect(queryServiceStart.getState()).toEqual({ + filters, + refreshInterval, + time, + query, + }); + }); +}); diff --git a/src/plugins/data/public/query/query_service.ts b/src/plugins/data/public/query/query_service.ts index 1b634fda289969..8b309c9821d3e1 100644 --- a/src/plugins/data/public/query/query_service.ts +++ b/src/plugins/data/public/query/query_service.ts @@ -15,7 +15,8 @@ import { createAddToQueryLog } from './lib'; import { TimefilterService } from './timefilter'; import type { TimefilterSetup } from './timefilter'; import { createSavedQueryService } from './saved_query/saved_query_service'; -import { createQueryStateObservable } from './state_sync/create_global_query_observable'; +import { createQueryStateObservable } from './state_sync/create_query_state_observable'; +import { getQueryState } from './query_state'; import type { QueryStringContract } from './query_string'; import { QueryStringManager } from './query_string'; import { getEsQueryConfig, TimeRange } from '../../common'; @@ -69,6 +70,7 @@ export class QueryService { timefilter: this.timefilter, queryString: this.queryStringManager, state$: this.state$, + getState: () => this.getQueryState(), }; } @@ -82,6 +84,7 @@ export class QueryService { queryString: this.queryStringManager, savedQueries: createSavedQueryService(http), state$: this.state$, + getState: () => this.getQueryState(), timefilter: this.timefilter, getEsQuery: (indexPattern: IndexPattern, timeRange?: TimeRange) => { const timeFilter = this.timefilter.timefilter.createFilter(indexPattern, timeRange); @@ -99,6 +102,14 @@ export class QueryService { public stop() { // nothing to do here yet } + + private getQueryState() { + return getQueryState({ + timefilter: this.timefilter, + queryString: this.queryStringManager, + filterManager: this.filterManager, + }); + } } /** @public */ diff --git a/src/plugins/data/public/query/query_state.ts b/src/plugins/data/public/query/query_state.ts new file mode 100644 index 00000000000000..77242c981bda2e --- /dev/null +++ b/src/plugins/data/public/query/query_state.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Filter } from '@kbn/es-query'; +import type { TimefilterSetup } from './timefilter'; +import type { FilterManager } from './filter_manager'; +import type { QueryStringContract } from './query_string'; +import type { RefreshInterval, TimeRange, Query } from '../../common'; + +/** + * All query state service state + */ +export interface QueryState { + time?: TimeRange; + refreshInterval?: RefreshInterval; + filters?: Filter[]; + query?: Query; +} + +export function getQueryState({ + timefilter: { timefilter }, + filterManager, + queryString, +}: { + timefilter: TimefilterSetup; + filterManager: FilterManager; + queryString: QueryStringContract; +}): QueryState { + return { + time: timefilter.getTime(), + refreshInterval: timefilter.getRefreshInterval(), + filters: filterManager.getFilters(), + query: queryString.getQuery(), + }; +} diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts index d1d3ea5865c7e9..515cc38783cbdd 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts @@ -7,16 +7,17 @@ */ import { Subscription } from 'rxjs'; +import { Filter, FilterStateStore } from '@kbn/es-query'; import { FilterManager } from '../filter_manager'; import { getFilter } from '../filter_manager/test_helpers/get_stub_filter'; -import { Filter, FilterStateStore, UI_SETTINGS } from '../../../common'; +import { UI_SETTINGS } from '../../../common'; import { coreMock } from '@kbn/core/public/mocks'; import { BaseStateContainer, createStateContainer, Storage } from '@kbn/kibana-utils-plugin/public'; import { QueryService, QueryStart } from '../query_service'; import { StubBrowserStorage } from '@kbn/test-jest-helpers'; import { connectToQueryState } from './connect_to_query_state'; import { TimefilterContract } from '../timefilter'; -import { QueryState } from './types'; +import { QueryState } from '../query_state'; import { createNowProviderMock } from '../../now_provider/mocks'; const connectToQueryGlobalState = (query: QueryStart, state: BaseStateContainer) => diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts index b9bb05841f161f..a625dff04b0a37 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts @@ -9,10 +9,12 @@ import { Subscription } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import _ from 'lodash'; +import { COMPARE_ALL_OPTIONS, compareFilters } from '@kbn/es-query'; import { BaseStateContainer } from '@kbn/kibana-utils-plugin/public'; import { QuerySetup, QueryStart } from '../query_service'; -import { QueryState, QueryStateChange } from './types'; -import { FilterStateStore, COMPARE_ALL_OPTIONS, compareFilters } from '../../../common'; +import { QueryState } from '../query_state'; +import { QueryStateChange } from './types'; +import { FilterStateStore } from '../../../common'; import { validateTimeRange } from '../timefilter'; /** diff --git a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts b/src/plugins/data/public/query/state_sync/create_query_state_observable.ts similarity index 79% rename from src/plugins/data/public/query/state_sync/create_global_query_observable.ts rename to src/plugins/data/public/query/state_sync/create_query_state_observable.ts index 2e054229a55da4..39e7802753ee2e 100644 --- a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts +++ b/src/plugins/data/public/query/state_sync/create_query_state_observable.ts @@ -8,16 +8,16 @@ import { Observable, Subscription } from 'rxjs'; import { map, tap } from 'rxjs/operators'; -import { isFilterPinned } from '@kbn/es-query'; +import { COMPARE_ALL_OPTIONS, compareFilters, isFilterPinned } from '@kbn/es-query'; import { createStateContainer } from '@kbn/kibana-utils-plugin/public'; import type { TimefilterSetup } from '../timefilter'; import { FilterManager } from '../filter_manager'; -import { QueryState, QueryStateChange } from '.'; -import { compareFilters, COMPARE_ALL_OPTIONS } from '../../../common'; +import { getQueryState, QueryState } from '../query_state'; +import { QueryStateChange } from './types'; import type { QueryStringContract } from '../query_string'; export function createQueryStateObservable({ - timefilter: { timefilter }, + timefilter, filterManager, queryString, }: { @@ -25,27 +25,24 @@ export function createQueryStateObservable({ filterManager: FilterManager; queryString: QueryStringContract; }): Observable<{ changes: QueryStateChange; state: QueryState }> { - return new Observable((subscriber) => { - const state = createStateContainer({ - time: timefilter.getTime(), - refreshInterval: timefilter.getRefreshInterval(), - filters: filterManager.getFilters(), - query: queryString.getQuery(), - }); + const state = createStateContainer( + getQueryState({ timefilter, filterManager, queryString }) + ); + return new Observable((subscriber) => { let currentChange: QueryStateChange = {}; const subs: Subscription[] = [ queryString.getUpdates$().subscribe(() => { currentChange.query = true; state.set({ ...state.get(), query: queryString.getQuery() }); }), - timefilter.getTimeUpdate$().subscribe(() => { + timefilter.timefilter.getTimeUpdate$().subscribe(() => { currentChange.time = true; - state.set({ ...state.get(), time: timefilter.getTime() }); + state.set({ ...state.get(), time: timefilter.timefilter.getTime() }); }), - timefilter.getRefreshIntervalUpdate$().subscribe(() => { + timefilter.timefilter.getRefreshIntervalUpdate$().subscribe(() => { currentChange.refreshInterval = true; - state.set({ ...state.get(), refreshInterval: timefilter.getRefreshInterval() }); + state.set({ ...state.get(), refreshInterval: timefilter.timefilter.getRefreshInterval() }); }), filterManager.getUpdates$().subscribe(() => { currentChange.filters = true; diff --git a/src/plugins/data/public/query/state_sync/index.ts b/src/plugins/data/public/query/state_sync/index.ts index 58740cfab06d00..ffeda864f51724 100644 --- a/src/plugins/data/public/query/state_sync/index.ts +++ b/src/plugins/data/public/query/state_sync/index.ts @@ -7,5 +7,5 @@ */ export { connectToQueryState } from './connect_to_query_state'; -export { syncQueryStateWithUrl } from './sync_state_with_url'; -export type { QueryState, QueryStateChange } from './types'; +export { syncQueryStateWithUrl, syncGlobalQueryStateWithUrl } from './sync_state_with_url'; +export type { QueryStateChange, GlobalQueryStateFromUrl } from './types'; diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts index edeaa7c772575e..feb9fc5238ab65 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts @@ -21,7 +21,7 @@ import { QueryService, QueryStart } from '../query_service'; import { StubBrowserStorage } from '@kbn/test-jest-helpers'; import { TimefilterContract } from '../timefilter'; import { syncQueryStateWithUrl } from './sync_state_with_url'; -import { QueryState } from './types'; +import { GlobalQueryStateFromUrl } from './types'; import { createNowProviderMock } from '../../now_provider/mocks'; const setupMock = coreMock.createSetup(); @@ -100,14 +100,14 @@ describe('sync_query_state_with_url', () => { test('when filters change, global filters synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); filterManager.setFilters([gF, aF]); - expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1); + expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1); stop(); }); test('when time range changes, time synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); timefilter.setTime({ from: 'now-30m', to: 'now' }); - expect(kbnUrlStateStorage.get('_g')?.time).toEqual({ + expect(kbnUrlStateStorage.get('_g')?.time).toEqual({ from: 'now-30m', to: 'now', }); @@ -117,7 +117,7 @@ describe('sync_query_state_with_url', () => { test('when refresh interval changes, refresh interval is synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); timefilter.setRefreshInterval({ pause: true, value: 100 }); - expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({ + expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({ pause: true, value: 100, }); diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts index fd52ca5ffc9797..030cc1f91d4fea 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts @@ -13,17 +13,17 @@ import { } from '@kbn/kibana-utils-plugin/public'; import { QuerySetup, QueryStart } from '../query_service'; import { connectToQueryState } from './connect_to_query_state'; -import { QueryState } from './types'; import { FilterStateStore } from '../../../common'; +import { GlobalQueryStateFromUrl } from './types'; const GLOBAL_STATE_STORAGE_KEY = '_g'; /** - * Helper to setup syncing of global data with the URL + * Helper to sync global query state {@link GlobalQueryStateFromUrl} with the URL (`_g` query param that is preserved between apps) * @param QueryService: either setup or start * @param kbnUrlStateStorage to use for syncing */ -export const syncQueryStateWithUrl = ( +export const syncGlobalQueryStateWithUrl = ( query: Pick, kbnUrlStateStorage: IKbnUrlStateStorage ) => { @@ -31,14 +31,15 @@ export const syncQueryStateWithUrl = ( timefilter: { timefilter }, filterManager, } = query; - const defaultState: QueryState = { + const defaultState: GlobalQueryStateFromUrl = { time: timefilter.getTime(), refreshInterval: timefilter.getRefreshInterval(), filters: filterManager.getGlobalFilters(), }; // retrieve current state from `_g` url - const initialStateFromUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY); + const initialStateFromUrl = + kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY); // remember whether there was info in the URL const hasInheritedQueryFromUrl = Boolean( @@ -46,7 +47,7 @@ export const syncQueryStateWithUrl = ( ); // prepare initial state, whatever was in URL takes precedences over current state in services - const initialState: QueryState = { + const initialState: GlobalQueryStateFromUrl = { ...defaultState, ...initialStateFromUrl, }; @@ -61,7 +62,7 @@ export const syncQueryStateWithUrl = ( // if there weren't any initial state in url, // then put _g key into url if (!initialStateFromUrl) { - kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, initialState, { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, initialState, { replace: true, }); } @@ -92,3 +93,8 @@ export const syncQueryStateWithUrl = ( hasInheritedQueryFromUrl, }; }; + +/** + * @deprecated use {@link syncGlobalQueryStateWithUrl} instead + */ +export const syncQueryStateWithUrl = syncGlobalQueryStateWithUrl; diff --git a/src/plugins/data/public/query/state_sync/types.ts b/src/plugins/data/public/query/state_sync/types.ts index 8bfd47987ab904..653dd36577b8d2 100644 --- a/src/plugins/data/public/query/state_sync/types.ts +++ b/src/plugins/data/public/query/state_sync/types.ts @@ -6,17 +6,9 @@ * Side Public License, v 1. */ -import { Filter, RefreshInterval, TimeRange, Query } from '../../../common'; - -/** - * All query state service state - */ -export interface QueryState { - time?: TimeRange; - refreshInterval?: RefreshInterval; - filters?: Filter[]; - query?: Query; -} +import type { Filter } from '@kbn/es-query'; +import type { QueryState } from '../query_state'; +import { RefreshInterval, TimeRange } from '../../../common/types'; type QueryStateChangePartial = { [P in keyof QueryState]?: boolean; @@ -26,3 +18,12 @@ export interface QueryStateChange extends QueryStateChangePartial { appFilters?: boolean; // specifies if app filters change globalFilters?: boolean; // specifies if global filters change } + +/** + * Part of {@link QueryState} serialized in the `_g` portion of Url + */ +export interface GlobalQueryStateFromUrl { + time?: TimeRange; + refreshInterval?: RefreshInterval; + filters?: Filter[]; +} diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx index ecfdd9e5c1c922..690bfa1f7acb85 100644 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx +++ b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx @@ -9,9 +9,9 @@ import React, { useState, FC, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { NoDataViewsComponent } from '@kbn/shared-ux-components'; import { EuiFlyoutBody } from '@elastic/eui'; import { DEFAULT_ASSETS_TO_IGNORE } from '@kbn/data-plugin/common'; +import { NoDataViewsPromptComponent } from '@kbn/shared-ux-prompt-no-data-views'; import { useKibana } from '../../shared_imports'; import { MatchedItem, DataViewEditorContext } from '../../types'; @@ -105,7 +105,7 @@ export const EmptyPrompts: FC = ({ return ( <> - setGoToForm(true)} canCreateNewDataView={application.capabilities.indexPatterns.save as boolean} dataViewsDocLink={docLinks.links.indexPatterns.introduction} diff --git a/src/plugins/data_views/public/index.ts b/src/plugins/data_views/public/index.ts index f6a0843babed6d..5b14ca9d250302 100644 --- a/src/plugins/data_views/public/index.ts +++ b/src/plugins/data_views/public/index.ts @@ -57,6 +57,7 @@ export type { HasDataViewsResponse, IndicesResponse, IndicesResponseModified, + IndicesViaSearchResponse, } from './types'; // Export plugin after all other imports diff --git a/src/plugins/data_views/public/mocks.ts b/src/plugins/data_views/public/mocks.ts index 61713c9406c235..3767c93be10e63 100644 --- a/src/plugins/data_views/public/mocks.ts +++ b/src/plugins/data_views/public/mocks.ts @@ -28,6 +28,7 @@ const createStartContract = (): Start => { get: jest.fn().mockReturnValue(Promise.resolve({})), clearCache: jest.fn(), getCanSaveSync: jest.fn(), + getIdsWithTitle: jest.fn(), } as unknown as jest.Mocked; }; diff --git a/src/plugins/data_views/public/services/has_data.ts b/src/plugins/data_views/public/services/has_data.ts index 76f6b39ec49823..d10f6a3d446f8d 100644 --- a/src/plugins/data_views/public/services/has_data.ts +++ b/src/plugins/data_views/public/services/has_data.ts @@ -8,7 +8,12 @@ import { CoreStart, HttpStart } from '@kbn/core/public'; import { DEFAULT_ASSETS_TO_IGNORE } from '../../common'; -import { HasDataViewsResponse, IndicesResponse, IndicesResponseModified } from '..'; +import { + HasDataViewsResponse, + IndicesResponse, + IndicesResponseModified, + IndicesViaSearchResponse, +} from '..'; export class HasData { private removeAliases = (source: IndicesResponseModified): boolean => !source.item.indices; @@ -77,6 +82,41 @@ export class HasData { return source; }; + private getIndicesViaSearch = async ({ + http, + pattern, + showAllIndices, + }: { + http: HttpStart; + pattern: string; + showAllIndices: boolean; + }): Promise => + http + .post(`/internal/search/ese`, { + body: JSON.stringify({ + params: { + ignore_unavailable: true, + expand_wildcards: showAllIndices ? 'all' : 'open', + index: pattern, + body: { + size: 0, // no hits + aggs: { + indices: { + terms: { + field: '_index', + size: 200, + }, + }, + }, + }, + }, + }), + }) + .then((resp) => { + return !!(resp && resp.total >= 0); + }) + .catch(() => false); + private getIndices = async ({ http, pattern, @@ -96,26 +136,29 @@ export class HasData { } else { return this.responseToItemArray(response); } - }) - .catch(() => []); + }); private checkLocalESData = (http: HttpStart): Promise => this.getIndices({ http, pattern: '*', showAllIndices: false, - }).then((dataSources: IndicesResponseModified[]) => { - return dataSources.some(this.isUserDataIndex); - }); + }) + .then((dataSources: IndicesResponseModified[]) => { + return dataSources.some(this.isUserDataIndex); + }) + .catch(() => this.getIndicesViaSearch({ http, pattern: '*', showAllIndices: false })); private checkRemoteESData = (http: HttpStart): Promise => this.getIndices({ http, pattern: '*:*', showAllIndices: false, - }).then((dataSources: IndicesResponseModified[]) => { - return !!dataSources.filter(this.removeAliases).length; - }); + }) + .then((dataSources: IndicesResponseModified[]) => { + return !!dataSources.filter(this.removeAliases).length; + }) + .catch(() => this.getIndicesViaSearch({ http, pattern: '*:*', showAllIndices: false })); // Data Views diff --git a/src/plugins/data_views/public/types.ts b/src/plugins/data_views/public/types.ts index 612f22335e72ac..f2d34961ab6e0f 100644 --- a/src/plugins/data_views/public/types.ts +++ b/src/plugins/data_views/public/types.ts @@ -56,6 +56,10 @@ export interface IndicesResponse { data_streams?: IndicesResponseItemDataStream[]; } +export interface IndicesViaSearchResponse { + total: number; +} + export interface HasDataViewsResponse { hasDataView: boolean; hasUserDataView: boolean; diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx index f2ac0d2bfa060f..ee35e10b6631a0 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx @@ -74,6 +74,7 @@ export const getTopNavLinks = ({ anchorElement, searchSource: savedSearch.searchSource, services, + savedQueryId: state.appStateContainer.getState().savedQuery, }); }, testId: 'discoverAlertsButton', diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx index d414919e567f98..71a0ef3df1b8c8 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx @@ -26,9 +26,15 @@ interface AlertsPopoverProps { onClose: () => void; anchorElement: HTMLElement; searchSource: ISearchSource; + savedQueryId?: string; } -export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPopoverProps) { +export function AlertsPopover({ + searchSource, + anchorElement, + savedQueryId, + onClose, +}: AlertsPopoverProps) { const dataView = searchSource.getField('index')!; const services = useDiscoverServices(); const { triggersActionsUi } = services; @@ -49,8 +55,9 @@ export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPo return { searchType: 'searchSource', searchConfiguration: nextSearchSource.getSerializedFields(), + savedQueryId, }; - }, [searchSource, services]); + }, [savedQueryId, searchSource, services]); const SearchThresholdAlertFlyout = useMemo(() => { if (!alertFlyoutVisible) { @@ -156,11 +163,13 @@ export function openAlertsPopover({ anchorElement, searchSource, services, + savedQueryId, }: { I18nContext: I18nStart['Context']; anchorElement: HTMLElement; searchSource: ISearchSource; services: DiscoverServices; + savedQueryId?: string; }) { if (isOpen) { closeAlertsPopover(); @@ -177,6 +186,7 @@ export function openAlertsPopover({ onClose={closeAlertsPopover} anchorElement={anchorElement} searchSource={searchSource} + savedQueryId={savedQueryId} /> diff --git a/src/plugins/discover/public/locator.ts b/src/plugins/discover/public/locator.ts index d1b4d735715509..eb4731bd44e647 100644 --- a/src/plugins/discover/public/locator.ts +++ b/src/plugins/discover/public/locator.ts @@ -8,7 +8,12 @@ import type { SerializableRecord } from '@kbn/utility-types'; import type { Filter } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import type { VIEW_MODE } from './components/view_mode_toggle'; @@ -126,7 +131,7 @@ export class DiscoverAppLocatorDefinition implements LocatorDefinition('_g', queryState, { useHash }, path); + path = setStateToKbnUrl('_g', queryState, { useHash }, path); path = setStateToKbnUrl('_a', appState, { useHash }, path); if (searchSessionId) { diff --git a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap index a0c34cfdfee07f..2ad9af679e8c6a 100644 --- a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap +++ b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap @@ -129,6 +129,7 @@ exports[`TableListView render list view 1`] = ` } /> } + onChange={[Function]} pagination={ Object { "initialPageIndex": 0, @@ -155,7 +156,11 @@ exports[`TableListView render list view 1`] = ` "toolsLeft": undefined, } } - sorting={true} + sorting={ + Object { + "sort": undefined, + } + } tableCaption="test caption" tableLayout="fixed" /> diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx index 13423047bc3f0b..ba76a6b879e61e 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx @@ -7,13 +7,24 @@ */ import { EuiEmptyPrompt } from '@elastic/eui'; -import { shallowWithIntl } from '@kbn/test-jest-helpers'; +import { shallowWithIntl, registerTestBed, TestBed } from '@kbn/test-jest-helpers'; import { ToastsStart } from '@kbn/core/public'; import React from 'react'; +import moment, { Moment } from 'moment'; +import { act } from 'react-dom/test-utils'; import { themeServiceMock, applicationServiceMock } from '@kbn/core/public/mocks'; -import { TableListView } from './table_list_view'; +import { TableListView, TableListViewProps } from './table_list_view'; -const requiredProps = { +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (handler: () => void) => handler, + }; +}); + +const requiredProps: TableListViewProps> = { entityName: 'test', entityNamePlural: 'tests', listingLimit: 5, @@ -30,6 +41,14 @@ const requiredProps = { }; describe('TableListView', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + test('render default empty prompt', async () => { const component = shallowWithIntl(); @@ -81,4 +100,149 @@ describe('TableListView', () => { expect(component).toMatchSnapshot(); }); + + describe('default columns', () => { + let testBed: TestBed; + + const tableColumns = [ + { + field: 'title', + name: 'Title', + sortable: true, + }, + { + field: 'description', + name: 'Description', + sortable: true, + }, + ]; + + const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2)); + const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)); + + const hits = [ + { + title: 'Item 1', + description: 'Item 1 description', + updatedAt: twoDaysAgo, + }, + { + title: 'Item 2', + description: 'Item 2 description', + // This is the latest updated and should come first in the table + updatedAt: yesterday, + }, + ]; + + const findItems = jest.fn(() => Promise.resolve({ total: hits.length, hits })); + + const defaultProps: TableListViewProps> = { + ...requiredProps, + tableColumns, + findItems, + createItem: () => undefined, + }; + + const setup = registerTestBed(TableListView, { defaultProps }); + + test('should add a "Last updated" column if "updatedAt" is provided', async () => { + await act(async () => { + testBed = await setup(); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 2', 'Item 2 description', 'yesterday'], // Comes first as it is the latest updated + ['Item 1', 'Item 1 description', '2 days ago'], + ]); + }); + + test('should not display relative time for items updated more than 7 days ago', async () => { + const updatedAtValues: Moment[] = []; + + const updatedHits = hits.map(({ title, description }, i) => { + const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i))); + updatedAtValues[i] = moment(updatedAt); + + return { + title, + description, + updatedAt, + }; + }); + + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: updatedHits.length, + hits: updatedHits, + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + // Renders the datetime with this format: "05/10/2022 @ 2:34 PM" + ['Item 1', 'Item 1 description', updatedAtValues[0].format('LL')], + ['Item 2', 'Item 2 description', updatedAtValues[1].format('LL')], + ]); + }); + + test('should not add a "Last updated" column if no "updatedAt" is provided', async () => { + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: hits.length, + hits: hits.map(({ title, description }) => ({ title, description })), + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 1', 'Item 1 description'], // Sorted by title + ['Item 2', 'Item 2 description'], + ]); + }); + + test('should not display anything if there is no updatedAt metadata for an item', async () => { + await act(async () => { + testBed = await setup({ + findItems: jest.fn(() => + Promise.resolve({ + total: hits.length + 1, + hits: [...hits, { title: 'Item 3', description: 'Item 3 description' }], + }) + ), + }); + }); + + const { component, table } = testBed!; + component.update(); + + const { tableCellsValues } = table.getMetaData('itemsInMemTable'); + + expect(tableCellsValues).toEqual([ + ['Item 2', 'Item 2 description', 'yesterday'], + ['Item 1', 'Item 1 description', '2 days ago'], + ['Item 3', 'Item 3 description', '-'], // Empty column as no updatedAt provided + ]); + }); + }); }); diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index ece2fa37cc832b..5baaaa78b76ec4 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -13,16 +13,21 @@ import { EuiConfirmModal, EuiEmptyPrompt, EuiInMemoryTable, + Criteria, + PropertySort, + Direction, EuiLink, EuiSpacer, EuiTableActionsColumnType, SearchFilterConfig, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; import { ThemeServiceStart, HttpFetchError, ToastsStart, ApplicationStart } from '@kbn/core/public'; import { debounce, keyBy, sortBy, uniq } from 'lodash'; import React from 'react'; +import moment from 'moment'; import { KibanaPageTemplate } from '../page_template'; import { toMountPoint } from '../util'; @@ -64,6 +69,7 @@ export interface TableListViewProps { export interface TableListViewState { items: V[]; hasInitialFetchReturned: boolean; + hasUpdatedAtMetadata: boolean | null; isFetchingItems: boolean; isDeletingItems: boolean; showDeleteModal: boolean; @@ -72,6 +78,10 @@ export interface TableListViewState { filter: string; selectedIds: string[]; totalItems: number; + tableSort?: { + field: keyof V; + direction: Direction; + }; } // saved object client does not support sorting by title because title is only mapped as analyzed @@ -94,10 +104,12 @@ class TableListView extends React.Component< initialPageSize: props.initialPageSize, pageSizeOptions: uniq([10, 20, 50, props.initialPageSize]).sort(), }; + this.state = { items: [], totalItems: 0, hasInitialFetchReturned: false, + hasUpdatedAtMetadata: null, isFetchingItems: false, isDeletingItems: false, showDeleteModal: false, @@ -120,6 +132,28 @@ class TableListView extends React.Component< this.fetchItems(); } + componentDidUpdate(prevProps: TableListViewProps, prevState: TableListViewState) { + if (this.state.hasUpdatedAtMetadata === null && prevState.items !== this.state.items) { + // We check if the saved object have the "updatedAt" metadata + // to render or not that column in the table + const hasUpdatedAtMetadata = Boolean( + this.state.items.find((item: { updatedAt?: string }) => Boolean(item.updatedAt)) + ); + + this.setState((prev) => { + return { + hasUpdatedAtMetadata, + tableSort: hasUpdatedAtMetadata + ? { + field: 'updatedAt' as keyof V, + direction: 'desc' as const, + } + : prev.tableSort, + }; + }); + } + } + debouncedFetch = debounce(async (filter: string) => { try { const response = await this.props.findItems(filter); @@ -420,6 +454,12 @@ class TableListView extends React.Component< ); } + onTableChange(criteria: Criteria) { + if (criteria.sort) { + this.setState({ tableSort: criteria.sort }); + } + } + renderTable() { const { searchFilters } = this.props; @@ -435,24 +475,6 @@ class TableListView extends React.Component< } : undefined; - const actions: EuiTableActionsColumnType['actions'] = [ - { - name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { - defaultMessage: 'Edit', - }), - description: i18n.translate( - 'kibana-react.tableListView.listing.table.editActionDescription', - { - defaultMessage: 'Edit', - } - ), - icon: 'pencil', - type: 'icon', - enabled: (v) => !(v as unknown as { error: string })?.error, - onClick: this.props.editItem, - }, - ]; - const search = { onChange: this.setFilter.bind(this), toolsLeft: this.renderToolsLeft(), @@ -464,17 +486,6 @@ class TableListView extends React.Component< filters: searchFilters ?? [], }; - const columns = this.props.tableColumns.slice(); - if (this.props.editItem) { - columns.push({ - name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { - defaultMessage: 'Actions', - }), - width: '100px', - actions, - }); - } - const noItemsMessage = ( extends React.Component< values={{ entityNamePlural: this.props.entityNamePlural }} /> ); + return ( extends React.Component< ); } + getTableColumns() { + const columns = this.props.tableColumns.slice(); + + // Add "Last update" column + if (this.state.hasUpdatedAtMetadata) { + const renderUpdatedAt = (dateTime?: string) => { + if (!dateTime) { + return ( + + - + + ); + } + const updatedAt = moment(dateTime); + + if (updatedAt.diff(moment(), 'days') > -7) { + return ( + + {(formattedDate: string) => ( + + {formattedDate} + + )} + + ); + } + return ( + + {updatedAt.format('LL')} + + ); + }; + + columns.push({ + field: 'updatedAt', + name: i18n.translate('kibana-react.tableListView.lastUpdatedColumnTitle', { + defaultMessage: 'Last updated', + }), + render: (field: string, record: { updatedAt?: string }) => + renderUpdatedAt(record.updatedAt), + sortable: true, + width: '150px', + }); + } + + // Add "Actions" column + if (this.props.editItem) { + const actions: EuiTableActionsColumnType['actions'] = [ + { + name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'kibana-react.tableListView.listing.table.editActionDescription', + { + defaultMessage: 'Edit', + } + ), + icon: 'pencil', + type: 'icon', + enabled: (v) => !(v as unknown as { error: string })?.error, + onClick: this.props.editItem, + }, + ]; + + columns.push({ + name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { + defaultMessage: 'Actions', + }), + width: '100px', + actions, + }); + } + + return columns; + } + renderCreateButton() { if (this.props.createItem) { return ( diff --git a/src/plugins/presentation_util/public/components/field_picker/field_search.tsx b/src/plugins/presentation_util/public/components/field_picker/field_search.tsx index 3f3dcfdef5c8b0..d3307f71988f1e 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_search.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_search.tsx @@ -103,7 +103,6 @@ export function FieldSearch({ })} ( } > - + {this.props.showFilter && ( ( diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 2515a8ce6d7888..4b3bc4f5bd0cf3 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -131,7 +131,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "field": "type", "name": "Type", "render": [Function], - "sortable": false, + "sortable": true, "width": "50px", }, Object { @@ -143,6 +143,13 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "render": [Function], "sortable": false, }, + Object { + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + "width": "150px", + }, Object { "actions": Array [ Object { @@ -215,6 +222,14 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "onSelectionChange": [Function], } } + sorting={ + Object { + "sort": Object { + "direction": "desc", + "field": "updated_at", + }, + } + } tableLayout="fixed" /> @@ -351,7 +366,7 @@ exports[`Table should render normally 1`] = ` "field": "type", "name": "Type", "render": [Function], - "sortable": false, + "sortable": true, "width": "50px", }, Object { @@ -363,6 +378,13 @@ exports[`Table should render normally 1`] = ` "render": [Function], "sortable": false, }, + Object { + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + "width": "150px", + }, Object { "actions": Array [ Object { @@ -435,6 +457,14 @@ exports[`Table should render normally 1`] = ` "onSelectionChange": [Function], } } + sorting={ + Object { + "sort": Object { + "direction": "desc", + "field": "updated_at", + }, + } + } tableLayout="fixed" /> diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 4ee1510a7627c8..86f2b766002acd 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -50,6 +50,10 @@ const defaultProps: TableProps = { canGoInApp: () => true, pageIndex: 1, pageSize: 2, + sort: { + field: 'updated_at', + direction: 'desc', + }, items: [ { id: '1', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index ff5d49da99c61b..0ffd353c8ddd2b 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -8,6 +8,7 @@ import { ApplicationStart, IBasePath } from '@kbn/core/public'; import React, { PureComponent, Fragment } from 'react'; +import moment from 'moment'; import { EuiSearchBar, EuiBasicTable, @@ -24,9 +25,10 @@ import { EuiTableFieldDataColumnType, EuiTableActionsColumnType, QueryType, + CriteriaWithPagination, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; import { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { SavedObjectManagementTypeInfo } from '../../../../common/types'; import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; @@ -55,6 +57,7 @@ export interface TableProps { goInspectObject: (obj: SavedObjectWithMetadata) => void; pageIndex: number; pageSize: number; + sort: CriteriaWithPagination['sort']; items: SavedObjectWithMetadata[]; itemId: string | (() => string); totalItemCount: number; @@ -128,10 +131,59 @@ export class Table extends PureComponent { this.setState({ isExportPopoverOpen: false }); }; + getUpdatedAtColumn = () => { + const renderUpdatedAt = (dateTime?: string) => { + if (!dateTime) { + return ( + + - + + ); + } + const updatedAt = moment(dateTime); + + if (updatedAt.diff(moment(), 'days') > -7) { + return ( + + {(formattedDate: string) => ( + + {formattedDate} + + )} + + ); + } + return ( + + {updatedAt.format('LL')} + + ); + }; + + return { + field: 'updated_at', + name: i18n.translate('savedObjectsManagement.objectsTable.table.lastUpdatedColumnTitle', { + defaultMessage: 'Last updated', + }), + render: (field: string, record: { updated_at?: string }) => + renderUpdatedAt(record.updated_at), + sortable: true, + width: '150px', + }; + }; + render() { const { pageIndex, pageSize, + sort, itemId, items, totalItemCount, @@ -186,7 +238,7 @@ export class Table extends PureComponent { 'savedObjectsManagement.objectsTable.table.columnTypeDescription', { defaultMessage: 'Type of the saved object' } ), - sortable: false, + sortable: true, 'data-test-subj': 'savedObjectsTableRowType', render: (type: string, object: SavedObjectWithMetadata) => { const typeLabel = getSavedObjectLabel(type, allowedTypes); @@ -239,6 +291,7 @@ export class Table extends PureComponent { 'data-test-subj': `savedObjectsTableColumn-${column.id}`, }; }), + this.getUpdatedAtColumn(), { name: i18n.translate('savedObjectsManagement.objectsTable.table.columnActionsName', { defaultMessage: 'Actions', @@ -422,6 +475,7 @@ export class Table extends PureComponent { items={items} columns={columns as any} pagination={pagination} + sorting={{ sort }} selection={selection} onChange={onTableChange} rowProps={(item) => ({ diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index c8330e0eb9cf30..b0afbcc163ef8c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -10,7 +10,7 @@ import React, { Component } from 'react'; import { debounce } from 'lodash'; // @ts-expect-error import { saveAs } from '@elastic/filesaver'; -import { EuiSpacer, Query } from '@elastic/eui'; +import { EuiSpacer, Query, CriteriaWithPagination } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SavedObjectsClientContract, @@ -78,6 +78,7 @@ export interface SavedObjectsTableState { totalCount: number; page: number; perPage: number; + sort: CriteriaWithPagination['sort']; savedObjects: SavedObjectWithMetadata[]; savedObjectCounts: Record; activeQuery: Query; @@ -114,6 +115,10 @@ export class SavedObjectsTable extends Component { typeToCountMap[type.name] = 0; @@ -211,7 +216,7 @@ export class SavedObjectsTable extends Component { - const { activeQuery: query, page, perPage } = this.state; + const { activeQuery: query, page, perPage, sort } = this.state; const { notifications, http, allowedTypes, taggingApi } = this.props; const { queryText, visibleTypes, selectedTags } = parseQuery(query, allowedTypes); @@ -228,9 +233,8 @@ export class SavedObjectsTable extends Component 1) { - findOptions.sortField = 'type'; - } + findOptions.sortField = sort?.field; + findOptions.sortOrder = sort?.direction; findOptions.hasReference = getTagFindReferences({ selectedTags, taggingApi }); @@ -352,7 +356,7 @@ export class SavedObjectsTable extends Component { + onTableChange = async (table: CriteriaWithPagination) => { const { index: page, size: perPage } = table.page || {}; this.setState( @@ -360,6 +364,7 @@ export class SavedObjectsTable extends Component { * URL. This part will be visible to the user, it can have user-friendly text. */ slug?: string; - - /** - * Whether to generate a slug automatically. If `true`, the slug will be - * a human-readable text consisting of three worlds: "--". - */ - humanReadableSlug?: boolean; } /** diff --git a/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts index 8a125206d1c800..693d06538e63ea 100644 --- a/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts +++ b/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts @@ -88,7 +88,6 @@ describe('create()', () => { body: expect.any(String), }); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toStrictEqual({ - humanReadableSlug: false, locatorId: LEGACY_SHORT_URL_LOCATOR_ID, params: { url: 'https://example.com/foo/bar', @@ -173,7 +172,6 @@ describe('createFromLongUrl()', () => { body: expect.any(String), }); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toStrictEqual({ - humanReadableSlug: true, locatorId: LEGACY_SHORT_URL_LOCATOR_ID, params: { url: '/a/b/c', diff --git a/src/plugins/share/public/url_service/short_urls/short_url_client.ts b/src/plugins/share/public/url_service/short_urls/short_url_client.ts index 63dcdc0b78718d..4a9dbf3909288f 100644 --- a/src/plugins/share/public/url_service/short_urls/short_url_client.ts +++ b/src/plugins/share/public/url_service/short_urls/short_url_client.ts @@ -59,7 +59,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { locator, params, slug = undefined, - humanReadableSlug = false, }: ShortUrlCreateParams

): Promise> { const { http } = this.dependencies; const data = await http.fetch>('/api/short_url', { @@ -67,7 +66,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { body: JSON.stringify({ locatorId: locator.id, slug, - humanReadableSlug, params, }), }); @@ -113,7 +111,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { const result = await this.createWithLocator({ locator, - humanReadableSlug: true, params: { url: relativeUrl, }, diff --git a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts index 1208f6fda4d1ed..97594837f0720e 100644 --- a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts +++ b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts @@ -26,6 +26,15 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { minLength: 3, maxLength: 255, }), + /** + * @deprecated + * + * This field is deprecated as the API does not support automatic + * human-readable slug generation. + * + * @todo This field will be removed in a future version. It is left + * here for backwards compatibility. + */ humanReadableSlug: schema.boolean({ defaultValue: false, }), @@ -36,7 +45,7 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { router.handleLegacyErrors(async (ctx, req, res) => { const savedObjects = (await ctx.core).savedObjects.client; const shortUrls = url.shortUrls.get({ savedObjects }); - const { locatorId, params, slug, humanReadableSlug } = req.body; + const { locatorId, params, slug } = req.body; const locator = url.locators.get(locatorId); if (!locator) { @@ -51,7 +60,6 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { locator, params, slug, - humanReadableSlug, }); return res.ok({ diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts index 5fc108cdbf56c1..fe6365d4986287 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts @@ -128,19 +128,6 @@ describe('ServerShortUrlClient', () => { }) ).rejects.toThrowError(new UrlServiceError(`Slug "lala" already exists.`, 'SLUG_EXISTS')); }); - - test('can automatically generate human-readable slug', async () => { - const { client, locator } = setup(); - const shortUrl = await client.create({ - locator, - humanReadableSlug: true, - params: { - url: '/app/test#foo/bar/baz', - }, - }); - - expect(shortUrl.data.slug.split('-').length).toBe(3); - }); }); describe('.get()', () => { diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.ts index 762ded11bf8eec..cecc4c3127135f 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.ts @@ -8,7 +8,6 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { SavedObjectReference } from '@kbn/core/server'; -import { generateSlug } from 'random-word-slugs'; import { ShortUrlRecord } from '.'; import type { IShortUrlClient, @@ -60,14 +59,13 @@ export class ServerShortUrlClient implements IShortUrlClient { locator, params, slug = '', - humanReadableSlug = false, }: ShortUrlCreateParams

): Promise> { if (slug) { validateSlug(slug); } if (!slug) { - slug = humanReadableSlug ? generateSlug() : randomStr(4); + slug = randomStr(5); } const { storage, currentVersion } = this.dependencies; diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 387b5e751ff44b..847140fd8e2721 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -42,7 +42,6 @@ export interface FilterItemProps { uiSettings: IUiSettingsClient; hiddenPanelOptions?: FilterPanelOption[]; timeRangeForSuggestionsOverride?: boolean; - readonly?: boolean; } type FilterPopoverProps = HTMLAttributes & EuiPopoverProps; @@ -364,7 +363,6 @@ export function FilterItem(props: FilterItemProps) { iconOnClick: handleIconClick, onClick: handleBadgeClick, 'data-test-subj': getDataTestSubj(valueLabelConfig), - readonly: props.readonly, }; const popoverProps: FilterPopoverProps = { @@ -379,18 +377,6 @@ export function FilterItem(props: FilterItemProps) { panelPaddingSize: 'none', }; - if (props.readonly) { - return ( - - - - ); - } - return ( {renderedComponent === 'menu' ? ( diff --git a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx index d399bb0025a109..0e107661398207 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx @@ -19,7 +19,6 @@ interface Props { fieldLabel?: string; filterLabelStatus: FilterLabelStatus; errorMessage?: string; - readonly?: boolean; hideAlias?: boolean; [propName: string]: any; } @@ -32,7 +31,6 @@ export const FilterView: FC = ({ fieldLabel, errorMessage, filterLabelStatus, - readonly, hideAlias, ...rest }: Props) => { @@ -56,45 +54,29 @@ export const FilterView: FC = ({ })} ${title}`; } - const badgeProps: EuiBadgeProps = readonly - ? { - title, - color: 'hollow', - onClick, - onClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemReadOnlyBadgeAriaLabel', - { - defaultMessage: 'Filter entry', - } - ), - iconOnClick, + const badgeProps: EuiBadgeProps = { + title, + color: 'hollow', + iconType: 'cross', + iconSide: 'right', + closeButtonProps: { + // Removing tab focus on close button because the same option can be obtained through the context menu + // Also, we may want to add a `DEL` keyboard press functionality + tabIndex: -1, + }, + iconOnClick, + iconOnClickAriaLabel: i18n.translate( + 'unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel', + { + defaultMessage: 'Delete {filter}', + values: { filter: innerText }, } - : { - title, - color: 'hollow', - iconType: 'cross', - iconSide: 'right', - closeButtonProps: { - // Removing tab focus on close button because the same option can be obtained through the context menu - // Also, we may want to add a `DEL` keyboard press functionality - tabIndex: -1, - }, - iconOnClick, - iconOnClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel', - { - defaultMessage: 'Delete {filter}', - values: { filter: innerText }, - } - ), - onClick, - onClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemBadgeAriaLabel', - { - defaultMessage: 'Filter actions', - } - ), - }; + ), + onClick, + onClickAriaLabel: i18n.translate('unifiedSearch.filter.filterBar.filterItemBadgeAriaLabel', { + defaultMessage: 'Filter actions', + }), + }; return ( diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 0ad4756e9177bb..d62a7f79c82de1 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -43,6 +43,7 @@ import { shallowEqual } from '../utils/shallow_equal'; import { AddFilterPopover } from './add_filter_popover'; import { DataViewPicker, DataViewPickerProps } from '../dataview_picker'; import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import './query_bar.scss'; const SuperDatePicker = React.memo( @@ -88,6 +89,7 @@ export interface QueryBarTopRowProps { filterBar?: React.ReactNode; showDatePickerAsBadge?: boolean; showSubmitButton?: boolean; + suggestionsSize?: SuggestionsListSize; isScreenshotMode?: boolean; } @@ -483,6 +485,7 @@ export const QueryBarTopRow = React.memo( timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} disableLanguageSwitcher={true} prepend={renderFilterMenuOnly() && renderFilterButtonGroup()} + size={props.suggestionsSize} /> )} diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a6ca444612402b..9d96ba936f708a 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -29,6 +29,7 @@ import { QueryBarMenu, QueryBarMenuProps } from '../query_string_input/query_bar import type { DataViewPickerProps } from '../dataview_picker'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; import { FilterBar, FilterItems } from '../filter_bar'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { searchBarStyles } from './search_bar.styles'; export interface SearchBarInjectedDeps { @@ -88,6 +89,8 @@ export interface SearchBarOwnProps { fillSubmitButton?: boolean; dataViewPickerComponentProps?: DataViewPickerProps; showSubmitButton?: boolean; + // defines size of suggestions query popover + suggestionsSize?: SuggestionsListSize; isScreenshotMode?: boolean; } @@ -485,6 +488,7 @@ class SearchBarUI extends Component { dataViewPickerComponentProps={this.props.dataViewPickerComponentProps} showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()} filterBar={filterBar} + suggestionsSize={this.props.suggestionsSize} isScreenshotMode={this.props.isScreenshotMode} /> diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.ts b/src/plugins/vis_types/vega/public/data_model/search_api.ts index 530449da9aa264..40238b445c8c29 100644 --- a/src/plugins/vis_types/vega/public/data_model/search_api.ts +++ b/src/plugins/vis_types/vega/public/data_model/search_api.ts @@ -8,7 +8,7 @@ import { combineLatest, from } from 'rxjs'; import { map, tap, switchMap } from 'rxjs/operators'; -import type { CoreStart, IUiSettingsClient, KibanaExecutionContext } from '@kbn/core/public'; +import type { IUiSettingsClient, KibanaExecutionContext } from '@kbn/core/public'; import { getSearchParamsFromRequest, SearchRequest, @@ -47,7 +47,6 @@ export const extendSearchParamsWithRuntimeFields = async ( export interface SearchAPIDependencies { uiSettings: IUiSettingsClient; - injectedMetadata: CoreStart['injectedMetadata']; search: DataPublicPluginStart['search']; indexPatterns: DataViewsPublicPluginStart; } diff --git a/src/plugins/vis_types/vega/public/plugin.ts b/src/plugins/vis_types/vega/public/plugin.ts index a95d6464273067..c9af49f009deeb 100644 --- a/src/plugins/vis_types/vega/public/plugin.ts +++ b/src/plugins/vis_types/vega/public/plugin.ts @@ -20,7 +20,6 @@ import { setDataViews, setInjectedVars, setUISettings, - setInjectedMetadata, setDocLinks, setMapsEms, } from './services'; @@ -73,7 +72,6 @@ export class VegaPlugin implements Plugin { ) { setInjectedVars({ enableExternalUrls: this.initializerContext.config.get().enableExternalUrls, - emsTileLayerId: core.injectedMetadata.getInjectedVar('emsTileLayerId', true), }); setUISettings(core.uiSettings); @@ -98,7 +96,6 @@ export class VegaPlugin implements Plugin { setNotifications(core.notifications); setData(data); setDataViews(dataViews); - setInjectedMetadata(core.injectedMetadata); setDocLinks(core.docLinks); setMapsEms(mapsEms); } diff --git a/src/plugins/vis_types/vega/public/services.ts b/src/plugins/vis_types/vega/public/services.ts index f7f0444803a004..304d9965f056d1 100644 --- a/src/plugins/vis_types/vega/public/services.ts +++ b/src/plugins/vis_types/vega/public/services.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { CoreStart, NotificationsStart, IUiSettingsClient, DocLinksStart } from '@kbn/core/public'; +import { NotificationsStart, IUiSettingsClient, DocLinksStart } from '@kbn/core/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -24,12 +24,8 @@ export const [getNotifications, setNotifications] = export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); export const [getMapsEms, setMapsEms] = createGetterSetter('mapsEms'); -export const [getInjectedMetadata, setInjectedMetadata] = - createGetterSetter('InjectedMetadata'); - export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ enableExternalUrls: boolean; - emsTileLayerId: unknown; }>('InjectedVars'); export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; diff --git a/src/plugins/vis_types/vega/public/vega_request_handler.ts b/src/plugins/vis_types/vega/public/vega_request_handler.ts index 8670fd9499529e..84b5663df0be6a 100644 --- a/src/plugins/vis_types/vega/public/vega_request_handler.ts +++ b/src/plugins/vis_types/vega/public/vega_request_handler.ts @@ -15,7 +15,7 @@ import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; import { VisParams } from './vega_fn'; -import { getData, getInjectedMetadata, getDataViews } from './services'; +import { getData, getDataViews } from './services'; import { VegaInspectorAdapters } from './vega_inspector'; interface VegaRequestHandlerParams { @@ -57,7 +57,6 @@ export function createVegaRequestHandler( uiSettings, search, indexPatterns: dataViews, - injectedMetadata: getInjectedMetadata(), }, context.abortSignal, context.inspectorAdapters, diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts index 6c0d693349ef6a..eafe75534154a7 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts +++ b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts @@ -116,7 +116,6 @@ describe('vega_map_view/view', () => { let vegaParser: VegaParser; setInjectedVars({ - emsTileLayerId: {}, enableExternalUrls: true, }); setData(dataPluginStart); @@ -150,7 +149,6 @@ describe('vega_map_view/view', () => { search: dataPluginStart.search, indexPatterns: dataViewsStart, uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, }), new TimeCache(dataPluginStart.query.timefilter.timefilter, 0), {}, diff --git a/src/plugins/vis_types/vega/public/vega_visualization.test.js b/src/plugins/vis_types/vega/public/vega_visualization.test.js index d1c821e9620213..024d935a2f3569 100644 --- a/src/plugins/vis_types/vega/public/vega_visualization.test.js +++ b/src/plugins/vis_types/vega/public/vega_visualization.test.js @@ -56,7 +56,6 @@ describe('VegaVisualizations', () => { beforeEach(() => { setInjectedVars({ - emsTileLayerId: {}, enableExternalUrls: true, }); setData(dataPluginStart); @@ -97,7 +96,6 @@ describe('VegaVisualizations', () => { search: dataPluginStart.search, indexPatterns: dataViewsPluginStart, uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, }), 0, 0, @@ -130,7 +128,6 @@ describe('VegaVisualizations', () => { search: dataPluginStart.search, indexPatterns: dataViewsPluginStart, uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, }), 0, 0, diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 5b8ba8ce04cb43..f5444b6269e221 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -64,10 +64,12 @@ export function mapHitSource( attributes, id, references, + updatedAt, }: { attributes: SavedObjectAttributes; id: string; references: SavedObjectReference[]; + updatedAt?: string; } ) { const newAttributes: { @@ -76,6 +78,7 @@ export function mapHitSource( url: string; savedObjectType?: string; editUrl?: string; + updatedAt?: string; type?: BaseVisType; icon?: BaseVisType['icon']; image?: BaseVisType['image']; @@ -85,6 +88,7 @@ export function mapHitSource( id, references, url: urlFor(id), + updatedAt, ...attributes, }; diff --git a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts index 2945aaa1a0cc8d..f113a0a212fe6e 100644 --- a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { SavedObject } from '@kbn/core/types/saved_objects'; +import type { SimpleSavedObject } from '@kbn/core/public'; import { BaseVisType } from './base_vis_type'; export type VisualizationStage = 'experimental' | 'beta' | 'production'; @@ -30,7 +30,7 @@ export interface VisualizationListItem { export interface VisualizationsAppExtension { docTypes: string[]; searchFields?: string[]; - toListItem: (savedObject: SavedObject) => VisualizationListItem; + toListItem: (savedObject: SimpleSavedObject) => VisualizationListItem; } export interface VisTypeAlias { diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts b/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts index fc41486fae84a2..1285da1f3bf159 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts @@ -8,7 +8,7 @@ import { ApplicationStart } from '@kbn/core/public'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { getUISettings } from '../../services'; import { GLOBAL_STATE_STORAGE_KEY, VISUALIZE_APP_NAME } from '../../../common/constants'; @@ -24,8 +24,14 @@ export const getVisualizeListItemLink = ( path: editApp ? editUrl : `#${editUrl}`, }); const useHash = getUISettings().get('state:storeInSessionStorage'); - const globalStateInUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; + const globalStateInUrl = + kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; - url = setStateToKbnUrl(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url); + url = setStateToKbnUrl( + GLOBAL_STATE_STORAGE_KEY, + globalStateInUrl, + { useHash }, + url + ); return url; }; diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index c1b6518f6684a9..c4fda918328f82 100644 --- a/test/api_integration/apis/custom_integration/integrations.ts +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -22,7 +22,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.be.an('array'); - expect(resp.body.length).to.be(43); + expect(resp.body.length).to.be(42); // Test for sample data card expect(resp.body.findIndex((c: { id: string }) => c.id === 'sample_data_all')).to.be.above( diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 1f73fcc34d97d9..e945d0f1f001af 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -158,6 +158,39 @@ export default function ({ getService }: FtrProviderContext) { }); }); }); + + describe('`sortField` and `sortOrder` parameters', () => { + it('sort objects by "type" in "asc" order', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find') + .query({ + type: ['visualization', 'dashboard'], + sortField: 'type', + sortOrder: 'asc', + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects.length).be.greaterThan(1); // Need more than 1 result for our test + expect(objects[0].type).to.be('dashboard'); + }); + }); + + it('sort objects by "type" in "desc" order', async () => { + await supertest + .get('/api/kibana/management/saved_objects/_find') + .query({ + type: ['visualization', 'dashboard'], + sortField: 'type', + sortOrder: 'desc', + }) + .expect(200) + .then((resp) => { + const objects = resp.body.saved_objects; + expect(objects[0].type).to.be('visualization'); + }); + }); + }); }); describe('meta attributes injected properly', () => { diff --git a/test/api_integration/apis/short_url/create_short_url/main.ts b/test/api_integration/apis/short_url/create_short_url/main.ts index 4eb6fa489b7254..d0b57a98731351 100644 --- a/test/api_integration/apis/short_url/create_short_url/main.ts +++ b/test/api_integration/apis/short_url/create_short_url/main.ts @@ -70,22 +70,6 @@ export default function ({ getService }: FtrProviderContext) { expect(response.body.url).to.be(''); }); - it('can generate a human-readable slug, composed of three words', async () => { - const response = await supertest.post('/api/short_url').send({ - locatorId: 'LEGACY_SHORT_URL_LOCATOR', - params: {}, - humanReadableSlug: true, - }); - - expect(response.status).to.be(200); - expect(typeof response.body.slug).to.be('string'); - const words = response.body.slug.split('-'); - expect(words.length).to.be(3); - for (const word of words) { - expect(word.length > 0).to.be(true); - } - }); - it('can create a short URL with custom slug', async () => { const rnd = Math.round(Math.random() * 1e6) + 1; const slug = 'test-slug-' + Date.now() + '-' + rnd; diff --git a/test/functional/apps/dashboard/group1/index.ts b/test/functional/apps/dashboard/group1/index.ts index 597102433ef45f..736dfd6f577f82 100644 --- a/test/functional/apps/dashboard/group1/index.ts +++ b/test/functional/apps/dashboard/group1/index.ts @@ -37,18 +37,5 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_unsaved_state')); loadTestFile(require.resolve('./dashboard_unsaved_listing')); loadTestFile(require.resolve('./edit_visualizations')); - loadTestFile(require.resolve('./dashboard_options')); - loadTestFile(require.resolve('./data_shared_attributes')); - loadTestFile(require.resolve('./share')); - loadTestFile(require.resolve('./embed_mode')); - loadTestFile(require.resolve('./dashboard_back_button')); - loadTestFile(require.resolve('./dashboard_error_handling')); - loadTestFile(require.resolve('./legacy_urls')); - loadTestFile(require.resolve('./saved_search_embeddable')); - - // Note: This one must be last because it unloads some data for one of its tests! - // No, this isn't ideal, but loading/unloading takes so much time and these are all bunched - // to improve efficiency... - loadTestFile(require.resolve('./dashboard_query_bar')); }); } diff --git a/test/functional/apps/dashboard/group6/config.ts b/test/functional/apps/dashboard/group6/config.ts new file mode 100644 index 00000000000000..a70a190ca63f81 --- /dev/null +++ b/test/functional/apps/dashboard/group6/config.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + }; +} diff --git a/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts b/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts new file mode 100644 index 00000000000000..c96e596a88ecfe --- /dev/null +++ b/test/functional/apps/dashboard/group6/create_and_add_embeddables.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { VisualizeConstants } from '@kbn/visualizations-plugin/common/constants'; +import { VISUALIZE_ENABLE_LABS_SETTING } from '@kbn/visualizations-plugin/common/constants'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); + const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + + describe('create and add embeddables', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + }); + + it('ensure toolbar popover closes on add', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAddNewEmbeddableLink('LOG_STREAM_EMBEDDABLE'); + await dashboardAddPanel.expectEditorMenuClosed(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + describe('add new visualization link', () => { + before(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.loadSavedDashboard('few panels'); + }); + + it('adds new visualization via the top nav link', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from top nav add new panel', + { redirectToOrigin: true } + ); + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new visualization', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from add new link', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new timelion visualization', async () => { + // adding this case, as the timelion agg-based viz doesn't need the `clickNewSearch()` step + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickAggBasedVisualizations(); + await PageObjects.visualize.clickTimelion(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'timelion visualization from add new link', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a markdown visualization via the quick button', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); + await dashboardAddPanel.clickMarkdownQuickButton(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from markdown quick button', + { redirectToOrigin: true } + ); + + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('saves the listing page instead of the visualization to the app link', async () => { + await PageObjects.header.clickVisualize(true); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).not.to.contain(VisualizeConstants.EDIT_PATH); + }); + + after(async () => { + await PageObjects.header.clickDashboard(); + }); + }); + + describe('visualize:enableLabs advanced setting', () => { + const LAB_VIS_NAME = 'Rendering Test: input control'; + + it('should display lab visualizations in add panel', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + const exists = await dashboardAddPanel.panelAddLinkExists(LAB_VIS_NAME); + await dashboardAddPanel.closeAddPanel(); + expect(exists).to.be(true); + }); + + describe('is false', () => { + before(async () => { + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickKibanaSettings(); + await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); + }); + + it('should not display lab visualizations in add panel', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + + const exists = await dashboardAddPanel.panelAddLinkExists(LAB_VIS_NAME); + await dashboardAddPanel.closeAddPanel(); + expect(exists).to.be(false); + }); + + after(async () => { + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickKibanaSettings(); + await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); + await PageObjects.header.clickDashboard(); + }); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard/group1/dashboard_back_button.ts b/test/functional/apps/dashboard/group6/dashboard_back_button.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_back_button.ts rename to test/functional/apps/dashboard/group6/dashboard_back_button.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_error_handling.ts b/test/functional/apps/dashboard/group6/dashboard_error_handling.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_error_handling.ts rename to test/functional/apps/dashboard/group6/dashboard_error_handling.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_options.ts b/test/functional/apps/dashboard/group6/dashboard_options.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_options.ts rename to test/functional/apps/dashboard/group6/dashboard_options.ts diff --git a/test/functional/apps/dashboard/group1/dashboard_query_bar.ts b/test/functional/apps/dashboard/group6/dashboard_query_bar.ts similarity index 100% rename from test/functional/apps/dashboard/group1/dashboard_query_bar.ts rename to test/functional/apps/dashboard/group6/dashboard_query_bar.ts diff --git a/test/functional/apps/dashboard/group1/data_shared_attributes.ts b/test/functional/apps/dashboard/group6/data_shared_attributes.ts similarity index 100% rename from test/functional/apps/dashboard/group1/data_shared_attributes.ts rename to test/functional/apps/dashboard/group6/data_shared_attributes.ts diff --git a/test/functional/apps/dashboard/group1/embed_mode.ts b/test/functional/apps/dashboard/group6/embed_mode.ts similarity index 100% rename from test/functional/apps/dashboard/group1/embed_mode.ts rename to test/functional/apps/dashboard/group6/embed_mode.ts diff --git a/test/functional/apps/dashboard/group6/empty_dashboard.ts b/test/functional/apps/dashboard/group6/empty_dashboard.ts new file mode 100644 index 00000000000000..e559c0ef81f607 --- /dev/null +++ b/test/functional/apps/dashboard/group6/empty_dashboard.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardExpect = getService('dashboardExpect'); + const PageObjects = getPageObjects(['common', 'dashboard']); + + describe('empty dashboard', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + after(async () => { + await dashboardAddPanel.closeAddPanel(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + it('should display empty widget', async () => { + const emptyWidgetExists = await testSubjects.exists('emptyDashboardWidget'); + expect(emptyWidgetExists).to.be(true); + }); + + it('should open add panel when add button is clicked', async () => { + await dashboardAddPanel.clickOpenAddPanel(); + const isAddPanelOpen = await dashboardAddPanel.isAddPanelOpen(); + expect(isAddPanelOpen).to.be(true); + await testSubjects.click('euiFlyoutCloseButton'); + }); + + it('should add new visualization from dashboard', async () => { + await dashboardVisualizations.createAndAddMarkdown({ + name: 'Dashboard Test Markdown', + markdown: 'Markdown text', + }); + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardExpect.markdownWithValuesExists(['Markdown text']); + }); + + it('should open editor menu when editor button is clicked', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await testSubjects.existOrFail('dashboardEditorContextMenu'); + }); + }); +} diff --git a/test/functional/apps/dashboard/group6/index.ts b/test/functional/apps/dashboard/group6/index.ts new file mode 100644 index 00000000000000..f78f7e2d549b8f --- /dev/null +++ b/test/functional/apps/dashboard/group6/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + + async function loadCurrentData() { + await browser.setWindowSize(1300, 900); + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + } + + async function unloadCurrentData() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + } + + describe('dashboard app - group 1', function () { + before(loadCurrentData); + after(unloadCurrentData); + + // This has to be first since the other tests create some embeddables as side affects and our counting assumes + // a fresh index. + loadTestFile(require.resolve('./empty_dashboard')); + loadTestFile(require.resolve('./dashboard_options')); + loadTestFile(require.resolve('./data_shared_attributes')); + loadTestFile(require.resolve('./share')); + loadTestFile(require.resolve('./embed_mode')); + loadTestFile(require.resolve('./dashboard_back_button')); + loadTestFile(require.resolve('./dashboard_error_handling')); + loadTestFile(require.resolve('./legacy_urls')); + loadTestFile(require.resolve('./saved_search_embeddable')); + + // Note: This one must be last because it unloads some data for one of its tests! + // No, this isn't ideal, but loading/unloading takes so much time and these are all bunched + // to improve efficiency... + loadTestFile(require.resolve('./dashboard_query_bar')); + }); +} diff --git a/test/functional/apps/dashboard/group1/legacy_urls.ts b/test/functional/apps/dashboard/group6/legacy_urls.ts similarity index 100% rename from test/functional/apps/dashboard/group1/legacy_urls.ts rename to test/functional/apps/dashboard/group6/legacy_urls.ts diff --git a/test/functional/apps/dashboard/group1/saved_search_embeddable.ts b/test/functional/apps/dashboard/group6/saved_search_embeddable.ts similarity index 100% rename from test/functional/apps/dashboard/group1/saved_search_embeddable.ts rename to test/functional/apps/dashboard/group6/saved_search_embeddable.ts diff --git a/test/functional/apps/dashboard/group1/share.ts b/test/functional/apps/dashboard/group6/share.ts similarity index 100% rename from test/functional/apps/dashboard/group1/share.ts rename to test/functional/apps/dashboard/group6/share.ts diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts index 23f44575ff45ef..4648698ec0b5fe 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -42,7 +42,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('apply new default width and grow', async () => { it('defaults to medium width and grow enabled', async () => { - await dashboardControls.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await dashboardControls.openCreateControlFlyout(); const mediumWidthButton = await testSubjects.find('control-editor-width-medium'); expect(await mediumWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be( true @@ -70,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await secondControl.elementHasClass('controlFrameWrapper--small')).to.be(true); expect(await secondControl.elementHasClass('euiFlexItem--flexGrowZero')).to.be(true); - await dashboardControls.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await dashboardControls.openCreateControlFlyout(); const smallWidthButton = await testSubjects.find('control-editor-width-small'); expect(await smallWidthButton.elementHasClass('euiButtonGroupButton-isSelected')).to.be( true diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 17a028a39464ef..162444883873aa 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -110,7 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('animals-*'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view @@ -129,7 +129,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListEnsurePopoverIsClosed(secondId); await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetfield('animal.keyword', OPTIONS_LIST_CONTROL); await dashboardControls.controlEditorSave(); const selectionString = await dashboardControls.optionsListGetSelectionsString(secondId); diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index a4b84206bde842..9cc390fbe405a9 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -121,7 +121,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await saveButton.isEnabled()).to.be(true); await dashboardControls.controlsEditorSetDataView('kibana_sample_data_flights'); expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield('dayOfWeek'); + await dashboardControls.controlsEditorSetfield('dayOfWeek', RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(); validateRange('placeholder', firstId, '0', '6'); @@ -164,7 +164,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('editing field clears selections', async () => { const secondId = (await dashboardControls.getAllControlIds())[1]; await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlsEditorSetfield('FlightDelayMin'); + await dashboardControls.controlsEditorSetfield('FlightDelayMin', RANGE_SLIDER_CONTROL); await dashboardControls.controlEditorSave(); await dashboardControls.rangeSliderWaitForLoading(); diff --git a/test/functional/apps/dashboard_elements/controls/replace_controls.ts b/test/functional/apps/dashboard_elements/controls/replace_controls.ts index f6af3999050772..3697300e1b7d3a 100644 --- a/test/functional/apps/dashboard_elements/controls/replace_controls.ts +++ b/test/functional/apps/dashboard_elements/controls/replace_controls.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect'; - import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, @@ -28,24 +26,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'header', ]); - const changeFieldType = async (newField: string) => { - const saveButton = await testSubjects.find('control-editor-save'); - expect(await saveButton.isEnabled()).to.be(false); - await dashboardControls.controlsEditorSetfield(newField); - expect(await saveButton.isEnabled()).to.be(true); + const changeFieldType = async (controlId: string, newField: string, expectedType?: string) => { + await dashboardControls.editExistingControl(controlId); + await dashboardControls.controlsEditorSetfield(newField, expectedType); await dashboardControls.controlEditorSave(); }; const replaceWithOptionsList = async (controlId: string) => { - await dashboardControls.controlEditorSetType(OPTIONS_LIST_CONTROL); - await changeFieldType('sound.keyword'); + await changeFieldType(controlId, 'sound.keyword', OPTIONS_LIST_CONTROL); await testSubjects.waitForEnabled(`optionsList-control-${controlId}`); await dashboardControls.verifyControlType(controlId, 'optionsList-control'); }; const replaceWithRangeSlider = async (controlId: string) => { - await dashboardControls.controlEditorSetType(RANGE_SLIDER_CONTROL); - await changeFieldType('weightLbs'); + await changeFieldType(controlId, 'weightLbs', RANGE_SLIDER_CONTROL); await retry.try(async () => { await dashboardControls.rangeSliderWaitForLoading(); await dashboardControls.verifyControlType(controlId, 'range-slider-control'); @@ -53,8 +47,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const replaceWithTimeSlider = async (controlId: string) => { - await dashboardControls.controlEditorSetType(TIME_SLIDER_CONTROL); - await changeFieldType('@timestamp'); + await changeFieldType(controlId, '@timestamp', TIME_SLIDER_CONTROL); await testSubjects.waitForDeleted('timeSlider-loading-spinner'); await dashboardControls.verifyControlType(controlId, 'timeSlider'); }; @@ -78,7 +71,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { fieldName: 'sound.keyword', }); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with range slider', async () => { @@ -102,7 +94,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await dashboardControls.rangeSliderWaitForLoading(); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with options list', async () => { @@ -124,7 +115,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await testSubjects.waitForDeleted('timeSlider-loading-spinner'); controlId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(controlId); }); it('with options list', async () => { diff --git a/test/functional/apps/discover/_chart_hidden.ts b/test/functional/apps/discover/_chart_hidden.ts index a9179fd2349050..44fa42e568a0b6 100644 --- a/test/functional/apps/discover/_chart_hidden.ts +++ b/test/functional/apps/discover/_chart_hidden.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { defaultIndex: 'logstash-*', }; - describe('discover show/hide chart test', function () { + // Failing: See https://github.com/elastic/kibana/issues/132288 + describe.skip('discover show/hide chart test', function () { before(async function () { log.debug('load kibana index with default index pattern'); diff --git a/test/functional/apps/discover/_context_encoded_url_param.ts b/test/functional/apps/discover/_context_encoded_url_param.ts index fdbee7a637f46d..95540c929130c4 100644 --- a/test/functional/apps/discover/_context_encoded_url_param.ts +++ b/test/functional/apps/discover/_context_encoded_url_param.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const es = getService('es'); - describe('encoded URL params in context page', () => { + // Failing: See https://github.com/elastic/kibana/issues/132553 + describe.skip('encoded URL params in context page', () => { before(async function () { await security.testUser.setRoles(['kibana_admin', 'context_encoded_param']); await PageObjects.common.navigateToApp('settings'); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index f0438b391ac932..2f8f21c73692e9 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -7,12 +7,22 @@ */ import expect from '@kbn/expect'; -import { OPTIONS_LIST_CONTROL, ControlWidth } from '@kbn/controls-plugin/common'; +import { + OPTIONS_LIST_CONTROL, + RANGE_SLIDER_CONTROL, + ControlWidth, +} from '@kbn/controls-plugin/common'; import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; import { FtrService } from '../ftr_provider_context'; +const CONTROL_DISPLAY_NAMES: { [key: string]: string } = { + default: 'Please select a field', + [OPTIONS_LIST_CONTROL]: 'Options list', + [RANGE_SLIDER_CONTROL]: 'Range slider', +}; + export class DashboardPageControls extends FtrService { private readonly log = this.ctx.getService('log'); private readonly find = this.ctx.getService('find'); @@ -78,14 +88,14 @@ export class DashboardPageControls extends FtrService { } } - public async openCreateControlFlyout(type: string) { - this.log.debug(`Opening flyout for ${type} control`); + public async openCreateControlFlyout() { + this.log.debug(`Opening flyout for creating a control`); await this.testSubjects.click('dashboard-controls-menu-button'); await this.testSubjects.click('controls-create-button'); await this.retry.try(async () => { await this.testSubjects.existOrFail('control-editor-flyout'); }); - await this.controlEditorSetType(type); + await this.controlEditorVerifyType('default'); } /* ----------------------------------------------------------- @@ -238,10 +248,12 @@ export class DashboardPageControls extends FtrService { grow?: boolean; }) { this.log.debug(`Creating ${controlType} control ${title ?? fieldName}`); - await this.openCreateControlFlyout(controlType); + await this.openCreateControlFlyout(); if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); - if (fieldName) await this.controlsEditorSetfield(fieldName); + + if (fieldName) await this.controlsEditorSetfield(fieldName, controlType); + if (title) await this.controlEditorSetTitle(title); if (width) await this.controlEditorSetWidth(width); if (grow !== undefined) await this.controlEditorSetGrow(grow); @@ -377,6 +389,9 @@ export class DashboardPageControls extends FtrService { public async controlEditorSave() { this.log.debug(`Saving changes in control editor`); await this.testSubjects.click(`control-editor-save`); + await this.retry.waitFor('flyout to close', async () => { + return !(await this.testSubjects.exists('control-editor-flyout')); + }); } public async controlEditorCancel(confirm?: boolean) { @@ -396,7 +411,11 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click(`data-view-picker-${dataViewTitle}`); } - public async controlsEditorSetfield(fieldName: string, shouldSearch: boolean = false) { + public async controlsEditorSetfield( + fieldName: string, + expectedType?: string, + shouldSearch: boolean = false + ) { this.log.debug(`Setting control field to ${fieldName}`); if (shouldSearch) { await this.testSubjects.setValue('field-search-input', fieldName); @@ -405,17 +424,19 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.existOrFail(`field-picker-select-${fieldName}`); }); await this.testSubjects.click(`field-picker-select-${fieldName}`); + if (expectedType) await this.controlEditorVerifyType(expectedType); } - public async controlEditorSetType(type: string) { - this.log.debug(`Setting control type to ${type}`); - await this.testSubjects.click(`create-${type}-control`); + public async controlEditorVerifyType(type: string) { + this.log.debug(`Verifying that the control editor picked the type ${type}`); + const autoSelectedType = await this.testSubjects.getVisibleText('control-editor-type'); + expect(autoSelectedType).to.equal(CONTROL_DISPLAY_NAMES[type]); } // Options List editor functions public async optionsListEditorGetCurrentDataView(openAndCloseFlyout?: boolean) { if (openAndCloseFlyout) { - await this.openCreateControlFlyout(OPTIONS_LIST_CONTROL); + await this.openCreateControlFlyout(); } const dataViewName = (await this.testSubjects.find('open-data-view-picker')).getVisibleText(); if (openAndCloseFlyout) { diff --git a/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts b/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts index f5ace7e055254a..c3697cea6a34e6 100644 --- a/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts +++ b/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts @@ -17,6 +17,11 @@ describe('isConnectorDeprecated', () => { isPreconfigured: false as const, }; + it('returns false if the config is not defined', () => { + // @ts-expect-error + expect(isConnectorDeprecated({})).toBe(false); + }); + it('returns false if the connector is not ITSM or SecOps', () => { expect(isConnectorDeprecated(connector)).toBe(false); }); @@ -48,4 +53,24 @@ describe('isConnectorDeprecated', () => { }) ).toBe(true); }); + + it('returns true if the connector is .servicenow and the usesTableApi is omitted', () => { + expect( + isConnectorDeprecated({ + ...connector, + actionTypeId: '.servicenow', + config: { apiUrl: 'http://example.com' }, + }) + ).toBe(true); + }); + + it('returns true if the connector is .servicenow-sir and the usesTableApi is omitted', () => { + expect( + isConnectorDeprecated({ + ...connector, + actionTypeId: '.servicenow-sir', + config: { apiUrl: 'http://example.com' }, + }) + ).toBe(true); + }); }); diff --git a/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts b/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts index 210631cb532f6a..ed46f5e685459b 100644 --- a/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts +++ b/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts @@ -5,11 +5,14 @@ * 2.0. */ +import { isPlainObject } from 'lodash'; import { PreConfiguredAction, RawAction } from '../types'; export type ConnectorWithOptionalDeprecation = Omit & Pick, 'isDeprecated'>; +const isObject = (obj: unknown): obj is Record => isPlainObject(obj); + export const isConnectorDeprecated = ( connector: RawAction | ConnectorWithOptionalDeprecation ): boolean => { @@ -18,11 +21,40 @@ export const isConnectorDeprecated = ( * Connectors after the Elastic ServiceNow application use the * Import Set API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_ImportSetAPI) * A ServiceNow connector is considered deprecated if it uses the Table API. - * - * All other connectors do not have the usesTableApi config property - * so the function will always return false for them. */ - return !!connector.config?.usesTableApi; + + /** + * We cannot deduct if the connector is + * deprecated without config. In this case + * we always return false. + */ + if (!isObject(connector.config)) { + return false; + } + + /** + * If the usesTableApi is not defined it means that the connector is created + * before the introduction of the usesTableApi property. In that case, the connector is assumed + * to be deprecated because all connectors prior 7.16 where using the Table API. + * Migrations x-pack/plugins/actions/server/saved_objects/actions_migrations.ts set + * the usesTableApi property to true to all connectors prior 7.16. Pre configured connectors + * cannot be migrated. This check ensures that pre configured connectors without the + * usesTableApi property explicitly in the kibana.yml file are considered deprecated. + * According to the schema defined here x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts + * if the property is not defined it will be set to true at the execution of the connector. + */ + if (!Object.hasOwn(connector.config, 'usesTableApi')) { + return true; + } + + /** + * Connector created prior to 7.16 will be migrated to have the usesTableApi property set to true. + * Connectors created after 7.16 should have the usesTableApi property set to true or false. + * If the usesTableApi is omitted on an API call it will be defaulted to true. Check the schema + * here x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts. + * The !! is to make TS happy. + */ + return !!connector.config.usesTableApi; } return false; diff --git a/x-pack/plugins/aiops/common/api/example_stream.ts b/x-pack/plugins/aiops/common/api/example_stream.ts index 1210cccf554879..ccef04fc8473a0 100644 --- a/x-pack/plugins/aiops/common/api/example_stream.ts +++ b/x-pack/plugins/aiops/common/api/example_stream.ts @@ -65,4 +65,7 @@ export function deleteEntityAction(payload: string): ApiActionDeleteEntity { }; } -export type ApiAction = ApiActionUpdateProgress | ApiActionAddToEntity | ApiActionDeleteEntity; +export type AiopsExampleStreamApiAction = + | ApiActionUpdateProgress + | ApiActionAddToEntity + | ApiActionDeleteEntity; diff --git a/x-pack/plugins/aiops/common/api/explain_log_rate_spikes.ts b/x-pack/plugins/aiops/common/api/explain_log_rate_spikes.ts new file mode 100644 index 00000000000000..b5c5524cdef01b --- /dev/null +++ b/x-pack/plugins/aiops/common/api/explain_log_rate_spikes.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const aiopsExplainLogRateSpikesSchema = schema.object({ + /** The index to query for log rate spikes */ + index: schema.string(), +}); + +export type AiopsExplainLogRateSpikesSchema = TypeOf; + +export const API_ACTION_NAME = { + ADD_FIELDS: 'add_fields', +} as const; +export type ApiActionName = typeof API_ACTION_NAME[keyof typeof API_ACTION_NAME]; + +interface ApiActionAddFields { + type: typeof API_ACTION_NAME.ADD_FIELDS; + payload: string[]; +} + +export function addFieldsAction(payload: string[]): ApiActionAddFields { + return { + type: API_ACTION_NAME.ADD_FIELDS, + payload, + }; +} + +export type AiopsExplainLogRateSpikesApiAction = ApiActionAddFields; diff --git a/x-pack/plugins/aiops/common/api/index.ts b/x-pack/plugins/aiops/common/api/index.ts index da1e091d3fb546..6b987fef13d1aa 100644 --- a/x-pack/plugins/aiops/common/api/index.ts +++ b/x-pack/plugins/aiops/common/api/index.ts @@ -5,15 +5,24 @@ * 2.0. */ -import type { AiopsExampleStreamSchema } from './example_stream'; +import type { + AiopsExplainLogRateSpikesSchema, + AiopsExplainLogRateSpikesApiAction, +} from './explain_log_rate_spikes'; +import type { AiopsExampleStreamSchema, AiopsExampleStreamApiAction } from './example_stream'; export const API_ENDPOINT = { EXAMPLE_STREAM: '/internal/aiops/example_stream', - ANOTHER: '/internal/aiops/another', + EXPLAIN_LOG_RATE_SPIKES: '/internal/aiops/explain_log_rate_spikes', } as const; export type ApiEndpoint = typeof API_ENDPOINT[keyof typeof API_ENDPOINT]; export interface ApiEndpointOptions { [API_ENDPOINT.EXAMPLE_STREAM]: AiopsExampleStreamSchema; - [API_ENDPOINT.ANOTHER]: { anotherOption: string }; + [API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES]: AiopsExplainLogRateSpikesSchema; +} + +export interface ApiEndpointActions { + [API_ENDPOINT.EXAMPLE_STREAM]: AiopsExampleStreamApiAction; + [API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES]: AiopsExplainLogRateSpikesApiAction; } diff --git a/x-pack/plugins/aiops/common/index.ts b/x-pack/plugins/aiops/common/index.ts index 0f4835d67ecc77..162fa9f1af624b 100755 --- a/x-pack/plugins/aiops/common/index.ts +++ b/x-pack/plugins/aiops/common/index.ts @@ -19,4 +19,4 @@ export const PLUGIN_NAME = 'AIOps'; * This is an internal hard coded feature flag so we can easily turn on/off the * "Explain log rate spikes UI" during development until the first release. */ -export const AIOPS_ENABLED = true; +export const AIOPS_ENABLED = false; diff --git a/x-pack/plugins/aiops/kibana.json b/x-pack/plugins/aiops/kibana.json index b74a23bf2bc9e9..2d1e60bca74e3e 100755 --- a/x-pack/plugins/aiops/kibana.json +++ b/x-pack/plugins/aiops/kibana.json @@ -9,7 +9,7 @@ "description": "AIOps plugin maintained by ML team.", "server": true, "ui": true, - "requiredPlugins": [], + "requiredPlugins": ["data"], "optionalPlugins": [], "requiredBundles": ["kibanaReact"], "extraPublicDirs": ["common"] diff --git a/x-pack/plugins/aiops/public/api/index.ts b/x-pack/plugins/aiops/public/api/index.ts deleted file mode 100644 index 6aa171df5286ce..00000000000000 --- a/x-pack/plugins/aiops/public/api/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { lazyLoadModules } from '../lazy_load_bundle'; - -import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; - -export async function getExplainLogRateSpikesComponent(): Promise<() => ExplainLogRateSpikesSpec> { - const modules = await lazyLoadModules(); - return () => modules.ExplainLogRateSpikes; -} diff --git a/x-pack/plugins/aiops/public/components/app.tsx b/x-pack/plugins/aiops/public/components/app.tsx deleted file mode 100755 index 963253b154e279..00000000000000 --- a/x-pack/plugins/aiops/public/components/app.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useEffect, useState } from 'react'; - -import { Chart, Settings, Axis, BarSeries, Position, ScaleType } from '@elastic/charts'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; - -import { - EuiBadge, - EuiButton, - EuiCheckbox, - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPageContentBody, - EuiPageContentHeader, - EuiProgress, - EuiSpacer, - EuiTitle, - EuiText, -} from '@elastic/eui'; - -import { getStatusMessage } from './get_status_message'; -import { initialState, resetStream, streamReducer } from './stream_reducer'; -import { useStreamFetchReducer } from './use_stream_fetch_reducer'; - -export const AiopsApp = () => { - const { notifications } = useKibana(); - - const [simulateErrors, setSimulateErrors] = useState(false); - - const { dispatch, start, cancel, data, isCancelled, isRunning } = useStreamFetchReducer( - '/internal/aiops/example_stream', - streamReducer, - initialState, - { simulateErrors } - ); - - const { errors, progress, entities } = data; - - const onClickHandler = async () => { - if (isRunning) { - cancel(); - } else { - dispatch(resetStream()); - start(); - } - }; - - useEffect(() => { - if (errors.length > 0) { - notifications.toasts.danger({ body: errors[errors.length - 1] }); - } - }, [errors, notifications.toasts]); - - const buttonLabel = isRunning - ? i18n.translate('xpack.aiops.stopbuttonText', { - defaultMessage: 'Stop development', - }) - : i18n.translate('xpack.aiops.startbuttonText', { - defaultMessage: 'Start development', - }); - - return ( - - - - - -

- -

- - - - - - - - {buttonLabel} - - - - - {progress}% - - - - - - - -
- - - - - - { - return { - x, - y, - }; - }) - .sort((a, b) => b.y - a.y)} - /> - -
-

{getStatusMessage(isRunning, isCancelled, data.progress)}

- setSimulateErrors(!simulateErrors)} - compressed - /> -
-
- - - - ); -}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx deleted file mode 100644 index 21d7b39a2a1486..00000000000000 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC } from 'react'; - -import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { I18nProvider } from '@kbn/i18n-react'; - -import { getCoreStart } from '../kibana_services'; - -import { AiopsApp } from './app'; - -/** - * Spec used for lazy loading in the ML plugin - */ -export type ExplainLogRateSpikesSpec = typeof ExplainLogRateSpikes; - -export const ExplainLogRateSpikes: FC = () => { - const coreStart = getCoreStart(); - - return ( - - - - - - - - ); -}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx new file mode 100644 index 00000000000000..12c4837194f807 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, FC } from 'react'; + +import { EuiBadge, EuiSpacer, EuiText } from '@elastic/eui'; + +import type { DataView } from '@kbn/data-views-plugin/public'; + +import { useStreamFetchReducer } from '../../hooks/use_stream_fetch_reducer'; + +import { initialState, streamReducer } from './stream_reducer'; + +/** + * ExplainLogRateSpikes props require a data view. + */ +export interface ExplainLogRateSpikesProps { + /** The data view to analyze. */ + dataView: DataView; +} + +export const ExplainLogRateSpikes: FC = ({ dataView }) => { + const { start, data, isRunning } = useStreamFetchReducer( + '/internal/aiops/explain_log_rate_spikes', + streamReducer, + initialState, + { index: dataView.title } + ); + + useEffect(() => { + start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + +

{dataView.title}

+

{isRunning ? 'Loading fields ...' : 'Loaded all fields.'}

+ + {data.fields.map((field) => ( + {field} + ))} +
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/index.ts b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/index.ts new file mode 100644 index 00000000000000..3e48c6816dda91 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { ExplainLogRateSpikesProps } from './explain_log_rate_spikes'; +import { ExplainLogRateSpikes } from './explain_log_rate_spikes'; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ExplainLogRateSpikes; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/stream_reducer.ts b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/stream_reducer.ts new file mode 100644 index 00000000000000..7ec710f4ae65d5 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/stream_reducer.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + API_ACTION_NAME, + AiopsExplainLogRateSpikesApiAction, +} from '../../../common/api/explain_log_rate_spikes'; + +interface StreamState { + fields: string[]; +} + +export const initialState: StreamState = { + fields: [], +}; + +export function streamReducer( + state: StreamState, + action: AiopsExplainLogRateSpikesApiAction | AiopsExplainLogRateSpikesApiAction[] +): StreamState { + if (Array.isArray(action)) { + return action.reduce(streamReducer, state); + } + + switch (action.type) { + case API_ACTION_NAME.ADD_FIELDS: + return { + fields: [...state.fields, ...action.payload], + }; + default: + return state; + } +} diff --git a/x-pack/plugins/aiops/public/components/get_status_message.tsx b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/get_status_message.tsx similarity index 100% rename from x-pack/plugins/aiops/public/components/get_status_message.tsx rename to x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/get_status_message.tsx diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/index.ts similarity index 52% rename from x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js rename to x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/index.ts index f450fc906d0762..38eb2795680519 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js +++ b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/index.ts @@ -5,6 +5,8 @@ * 2.0. */ -export { clusterSetupStatusRoute } from './cluster_setup_status'; -export { nodeSetupStatusRoute } from './node_setup_status'; -export { disableElasticsearchInternalCollectionRoute } from './disable_elasticsearch_internal_collection'; +import { SingleEndpointStreamingDemo } from './single_endpoint_streaming_demo'; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default SingleEndpointStreamingDemo; diff --git a/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/single_endpoint_streaming_demo.tsx b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/single_endpoint_streaming_demo.tsx new file mode 100644 index 00000000000000..12f33aada133c8 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/single_endpoint_streaming_demo.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState, FC } from 'react'; + +import { Chart, Settings, Axis, BarSeries, Position, ScaleType } from '@elastic/charts'; + +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import { + EuiBadge, + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { useStreamFetchReducer } from '../../hooks/use_stream_fetch_reducer'; + +import { getStatusMessage } from './get_status_message'; +import { initialState, resetStream, streamReducer } from './stream_reducer'; + +export const SingleEndpointStreamingDemo: FC = () => { + const { notifications } = useKibana(); + + const [simulateErrors, setSimulateErrors] = useState(false); + + const { dispatch, start, cancel, data, isCancelled, isRunning } = useStreamFetchReducer( + '/internal/aiops/example_stream', + streamReducer, + initialState, + { simulateErrors } + ); + + const { errors, progress, entities } = data; + + const onClickHandler = async () => { + if (isRunning) { + cancel(); + } else { + dispatch(resetStream()); + start(); + } + }; + + useEffect(() => { + if (errors.length > 0) { + notifications.toasts.danger({ body: errors[errors.length - 1] }); + } + }, [errors, notifications.toasts]); + + const buttonLabel = isRunning + ? i18n.translate('xpack.aiops.stopbuttonText', { + defaultMessage: 'Stop development', + }) + : i18n.translate('xpack.aiops.startbuttonText', { + defaultMessage: 'Start development', + }); + + return ( + + + + + {buttonLabel} + + + + + {progress}% + + + + + + + +
+ + + + + + { + return { + x, + y, + }; + }) + .sort((a, b) => b.y - a.y)} + /> + +
+

{getStatusMessage(isRunning, isCancelled, data.progress)}

+ setSimulateErrors(!simulateErrors)} + compressed + /> +
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/stream_reducer.ts b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/stream_reducer.ts similarity index 92% rename from x-pack/plugins/aiops/public/components/stream_reducer.ts rename to x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/stream_reducer.ts index 3e68e139ceecae..a3e9724f24a1f9 100644 --- a/x-pack/plugins/aiops/public/components/stream_reducer.ts +++ b/x-pack/plugins/aiops/public/components/single_endpoint_streaming_demo/stream_reducer.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ApiAction, API_ACTION_NAME } from '../../common/api/example_stream'; +import { AiopsExampleStreamApiAction, API_ACTION_NAME } from '../../../common/api/example_stream'; export const UI_ACTION_NAME = { ERROR: 'error', @@ -37,7 +37,7 @@ export function resetStream(): UiActionResetStream { } type UiAction = UiActionResetStream | UiActionError; -export type ReducerAction = ApiAction | UiAction; +export type ReducerAction = AiopsExampleStreamApiAction | UiAction; export function streamReducer( state: StreamState, action: ReducerAction | ReducerAction[] diff --git a/x-pack/plugins/aiops/public/components/stream_fetch.ts b/x-pack/plugins/aiops/public/hooks/stream_fetch.ts similarity index 62% rename from x-pack/plugins/aiops/public/components/stream_fetch.ts rename to x-pack/plugins/aiops/public/hooks/stream_fetch.ts index 37d7c13dd3b55b..abfec63702012f 100644 --- a/x-pack/plugins/aiops/public/components/stream_fetch.ts +++ b/x-pack/plugins/aiops/public/hooks/stream_fetch.ts @@ -7,14 +7,19 @@ import type React from 'react'; -import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; +import type { ApiEndpoint, ApiEndpointActions, ApiEndpointOptions } from '../../common/api'; -export async function* streamFetch( +interface ErrorAction { + type: 'error'; + payload: string; +} + +export async function* streamFetch( endpoint: E, abortCtrl: React.MutableRefObject, - options: ApiEndpointOptions[ApiEndpoint], + options: ApiEndpointOptions[E], basePath = '' -) { +): AsyncGenerator> { const stream = await fetch(`${basePath}${endpoint}`, { signal: abortCtrl.current.signal, method: 'POST', @@ -36,7 +41,7 @@ export async function* streamFetch( const bufferBounce = 100; let partial = ''; - let actionBuffer: A[] = []; + let actionBuffer: Array = []; let lastCall = 0; while (true) { @@ -52,7 +57,7 @@ export async function* streamFetch( partial = last ?? ''; - const actions = parts.map((p) => JSON.parse(p)); + const actions = parts.map((p) => JSON.parse(p)) as Array; actionBuffer.push(...actions); const now = Date.now(); @@ -61,10 +66,26 @@ export async function* streamFetch( yield actionBuffer; actionBuffer = []; lastCall = now; + + // In cases where the next chunk takes longer to be received than the `bufferBounce` timeout, + // we trigger this client side timeout to clear a potential intermediate buffer state. + // Since `yield` cannot be passed on to other scopes like callbacks, + // this pattern using a Promise is used to wait for the timeout. + yield new Promise>((resolve) => { + setTimeout(() => { + if (actionBuffer.length > 0) { + resolve(actionBuffer); + actionBuffer = []; + lastCall = now; + } else { + resolve([]); + } + }, bufferBounce + 10); + }); } } catch (error) { if (error.name !== 'AbortError') { - yield { type: 'error', payload: error.toString() }; + yield [{ type: 'error', payload: error.toString() }]; } break; } diff --git a/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts b/x-pack/plugins/aiops/public/hooks/use_stream_fetch_reducer.ts similarity index 77% rename from x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts rename to x-pack/plugins/aiops/public/hooks/use_stream_fetch_reducer.ts index 77ac09e0ff4297..ba64831bec60e2 100644 --- a/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts +++ b/x-pack/plugins/aiops/public/hooks/use_stream_fetch_reducer.ts @@ -5,7 +5,15 @@ * 2.0. */ -import { useReducer, useRef, useState, Reducer, ReducerAction, ReducerState } from 'react'; +import { + useEffect, + useReducer, + useRef, + useState, + Reducer, + ReducerAction, + ReducerState, +} from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; @@ -13,11 +21,11 @@ import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; import { streamFetch } from './stream_fetch'; -export const useStreamFetchReducer = , E = ApiEndpoint>( +export const useStreamFetchReducer = , E extends ApiEndpoint>( endpoint: E, reducer: R, initialState: ReducerState, - options: ApiEndpointOptions[ApiEndpoint] + options: ApiEndpointOptions[E] ) => { const kibana = useKibana(); @@ -44,7 +52,9 @@ export const useStreamFetchReducer = , E = ApiEndpoi options, kibana.services.http?.basePath.get() )) { - dispatch(actions as ReducerAction); + if (actions.length > 0) { + dispatch(actions as ReducerAction); + } } setIsRunning(false); @@ -56,6 +66,11 @@ export const useStreamFetchReducer = , E = ApiEndpoi setIsRunning(false); }; + // If components using this custom hook get unmounted, cancel any ongoing request. + useEffect(() => { + return () => abortCtrl.current.abort(); + }, []); + return { cancel, data, diff --git a/x-pack/plugins/aiops/public/index.ts b/x-pack/plugins/aiops/public/index.ts index 30bcaf5afabdcc..53fc1d7a6eecac 100755 --- a/x-pack/plugins/aiops/public/index.ts +++ b/x-pack/plugins/aiops/public/index.ts @@ -13,6 +13,6 @@ export function plugin() { return new AiopsPlugin(); } +export type { ExplainLogRateSpikesProps } from './components/explain_log_rate_spikes'; +export { ExplainLogRateSpikes, SingleEndpointStreamingDemo } from './shared_lazy_components'; export type { AiopsPluginSetup, AiopsPluginStart } from './types'; - -export type { ExplainLogRateSpikesSpec } from './components/explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/public/kibana_services.ts b/x-pack/plugins/aiops/public/kibana_services.ts deleted file mode 100644 index 9a43d2de5e5a18..00000000000000 --- a/x-pack/plugins/aiops/public/kibana_services.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreStart } from '@kbn/core/public'; -import { AppPluginStartDependencies } from './types'; - -let coreStart: CoreStart; -let pluginsStart: AppPluginStartDependencies; -export function setStartServices(core: CoreStart, plugins: AppPluginStartDependencies) { - coreStart = core; - pluginsStart = plugins; -} - -export const getCoreStart = () => coreStart; -export const getPluginsStart = () => pluginsStart; diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts b/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts deleted file mode 100644 index 00723360801759..00000000000000 --- a/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; - -let loadModulesPromise: Promise; - -interface LazyLoadedModules { - ExplainLogRateSpikes: ExplainLogRateSpikesSpec; -} - -export async function lazyLoadModules(): Promise { - if (typeof loadModulesPromise !== 'undefined') { - return loadModulesPromise; - } - - loadModulesPromise = new Promise(async (resolve, reject) => { - try { - const lazyImports = await import('./lazy'); - resolve({ ...lazyImports }); - } catch (error) { - reject(error); - } - }); - return loadModulesPromise; -} diff --git a/x-pack/plugins/aiops/public/plugin.ts b/x-pack/plugins/aiops/public/plugin.ts index 3c3cff39abb803..ef65ab247c40fc 100755 --- a/x-pack/plugins/aiops/public/plugin.ts +++ b/x-pack/plugins/aiops/public/plugin.ts @@ -7,19 +7,10 @@ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { getExplainLogRateSpikesComponent } from './api'; -import { setStartServices } from './kibana_services'; import { AiopsPluginSetup, AiopsPluginStart } from './types'; export class AiopsPlugin implements Plugin { public setup(core: CoreSetup) {} - - public start(core: CoreStart) { - setStartServices(core, {}); - return { - getExplainLogRateSpikesComponent, - }; - } - + public start(core: CoreStart) {} public stop() {} } diff --git a/x-pack/plugins/aiops/public/shared_lazy_components.tsx b/x-pack/plugins/aiops/public/shared_lazy_components.tsx new file mode 100644 index 00000000000000..f707a77cf7f905 --- /dev/null +++ b/x-pack/plugins/aiops/public/shared_lazy_components.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, Suspense } from 'react'; + +import { EuiErrorBoundary, EuiLoadingContent } from '@elastic/eui'; + +import type { ExplainLogRateSpikesProps } from './components/explain_log_rate_spikes'; + +const ExplainLogRateSpikesLazy = React.lazy(() => import('./components/explain_log_rate_spikes')); +const SingleEndpointStreamingDemoLazy = React.lazy( + () => import('./components/single_endpoint_streaming_demo') +); + +const LazyWrapper: FC = ({ children }) => ( + + }>{children} + +); + +/** + * Lazy-wrapped ExplainLogRateSpikes React component + * @param {ExplainLogRateSpikesProps} props - properties specifying the data on which to run the analysis. + */ +export const ExplainLogRateSpikes: FC = (props) => ( + + + +); + +/** + * Lazy-wrapped SingleEndpointStreamingDemo React component + */ +export const SingleEndpointStreamingDemo: FC = () => ( + + + +); diff --git a/x-pack/plugins/aiops/server/lib/accept_compression.test.ts b/x-pack/plugins/aiops/server/lib/accept_compression.test.ts new file mode 100644 index 00000000000000..f1c51f75cbe0c9 --- /dev/null +++ b/x-pack/plugins/aiops/server/lib/accept_compression.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { acceptCompression } from './accept_compression'; + +describe('acceptCompression', () => { + it('should return false for empty headers', () => { + expect(acceptCompression({})).toBe(false); + }); + it('should return false for other header containing gzip as string', () => { + expect(acceptCompression({ 'other-header': 'gzip, other' })).toBe(false); + }); + it('should return false for other header containing gzip as array', () => { + expect(acceptCompression({ 'other-header': ['gzip', 'other'] })).toBe(false); + }); + it('should return true for upper-case header containing gzip as string', () => { + expect(acceptCompression({ 'Accept-Encoding': 'gzip, other' })).toBe(true); + }); + it('should return true for lower-case header containing gzip as string', () => { + expect(acceptCompression({ 'accept-encoding': 'gzip, other' })).toBe(true); + }); + it('should return true for upper-case header containing gzip as array', () => { + expect(acceptCompression({ 'Accept-Encoding': ['gzip', 'other'] })).toBe(true); + }); + it('should return true for lower-case header containing gzip as array', () => { + expect(acceptCompression({ 'accept-encoding': ['gzip', 'other'] })).toBe(true); + }); + it('should return true for mixed headers containing gzip as string', () => { + expect( + acceptCompression({ 'accept-encoding': 'gzip, other', 'other-header': 'other-value' }) + ).toBe(true); + }); + it('should return true for mixed headers containing gzip as array', () => { + expect( + acceptCompression({ 'accept-encoding': ['gzip', 'other'], 'other-header': 'other-value' }) + ).toBe(true); + }); +}); diff --git a/x-pack/plugins/aiops/server/lib/accept_compression.ts b/x-pack/plugins/aiops/server/lib/accept_compression.ts new file mode 100644 index 00000000000000..0fd092d6473149 --- /dev/null +++ b/x-pack/plugins/aiops/server/lib/accept_compression.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Headers } from '@kbn/core/server'; + +/** + * Returns whether request headers accept a response using gzip compression. + * + * @param headers - Request headers. + * @returns boolean + */ +export function acceptCompression(headers: Headers) { + let compressed = false; + + Object.keys(headers).forEach((key) => { + if (key.toLocaleLowerCase() === 'accept-encoding') { + const acceptEncoding = headers[key]; + + function containsGzip(s: string) { + return s + .split(',') + .map((d) => d.trim()) + .includes('gzip'); + } + + if (typeof acceptEncoding === 'string') { + compressed = containsGzip(acceptEncoding); + } else if (Array.isArray(acceptEncoding)) { + for (const ae of acceptEncoding) { + if (containsGzip(ae)) { + compressed = true; + break; + } + } + } + } + }); + + return compressed; +} diff --git a/x-pack/plugins/aiops/server/lib/stream_factory.test.ts b/x-pack/plugins/aiops/server/lib/stream_factory.test.ts new file mode 100644 index 00000000000000..7082a4e7e763cc --- /dev/null +++ b/x-pack/plugins/aiops/server/lib/stream_factory.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import zlib from 'zlib'; + +import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; + +import { API_ENDPOINT } from '../../common/api'; +import type { ApiEndpointActions } from '../../common/api'; + +import { streamFactory } from './stream_factory'; + +type Action = ApiEndpointActions['/internal/aiops/explain_log_rate_spikes']; + +const mockItem1: Action = { + type: 'add_fields', + payload: ['clientip'], +}; +const mockItem2: Action = { + type: 'add_fields', + payload: ['referer'], +}; + +describe('streamFactory', () => { + let mockLogger: MockedLogger; + + beforeEach(() => { + mockLogger = loggerMock.create(); + }); + + it('should encode and receive an uncompressed stream', async () => { + const { DELIMITER, end, push, responseWithHeaders, stream } = streamFactory< + typeof API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES + >(mockLogger, {}); + + push(mockItem1); + push(mockItem2); + end(); + + let streamResult = ''; + for await (const chunk of stream) { + streamResult += chunk.toString('utf8'); + } + + const streamItems = streamResult.split(DELIMITER); + const lastItem = streamItems.pop(); + + const parsedItems = streamItems.map((d) => JSON.parse(d)); + + expect(responseWithHeaders.headers).toBe(undefined); + expect(parsedItems).toHaveLength(2); + expect(parsedItems[0]).toStrictEqual(mockItem1); + expect(parsedItems[1]).toStrictEqual(mockItem2); + expect(lastItem).toBe(''); + }); + + // Because zlib.gunzip's API expects a callback, we need to use `done` here + // to indicate once all assertions are run. However, it's not allowed to use both + // `async` and `done` for the test callback. That's why we're using an "async IIFE" + // pattern inside the tests callback to still be able to do async/await for the + // `for await()` part. Note that the unzipping here is done just to be able to + // decode the stream for the test and assert it. When used in actual code, + // the browser on the client side will automatically take care of unzipping + // without the need for additional custom code. + it('should encode and receive a compressed stream', (done) => { + (async () => { + const { DELIMITER, end, push, responseWithHeaders, stream } = streamFactory< + typeof API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES + >(mockLogger, { 'accept-encoding': 'gzip' }); + + push(mockItem1); + push(mockItem2); + end(); + + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const buffer = Buffer.concat(chunks); + + zlib.gunzip(buffer, function (err, decoded) { + expect(err).toBe(null); + + const streamResult = decoded.toString('utf8'); + + const streamItems = streamResult.split(DELIMITER); + const lastItem = streamItems.pop(); + + const parsedItems = streamItems.map((d) => JSON.parse(d)); + + expect(responseWithHeaders.headers).toStrictEqual({ 'content-encoding': 'gzip' }); + expect(parsedItems).toHaveLength(2); + expect(parsedItems[0]).toStrictEqual(mockItem1); + expect(parsedItems[1]).toStrictEqual(mockItem2); + expect(lastItem).toBe(''); + + done(); + }); + })(); + }); +}); diff --git a/x-pack/plugins/aiops/server/lib/stream_factory.ts b/x-pack/plugins/aiops/server/lib/stream_factory.ts new file mode 100644 index 00000000000000..dc67a549025273 --- /dev/null +++ b/x-pack/plugins/aiops/server/lib/stream_factory.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Stream } from 'stream'; +import zlib from 'zlib'; + +import type { Headers, Logger } from '@kbn/core/server'; + +import { ApiEndpoint, ApiEndpointActions } from '../../common/api'; + +import { acceptCompression } from './accept_compression'; + +// We need this otherwise Kibana server will crash with a 'ERR_METHOD_NOT_IMPLEMENTED' error. +class ResponseStream extends Stream.PassThrough { + flush() {} + _read() {} +} + +const DELIMITER = '\n'; + +/** + * Sets up a response stream with support for gzip compression depending on provided + * request headers. + * + * @param logger - Kibana provided logger. + * @param headers - Request headers. + * @returns An object with stream attributes and methods. + */ +export function streamFactory(logger: Logger, headers: Headers) { + const isCompressed = acceptCompression(headers); + + const stream = isCompressed ? zlib.createGzip() : new ResponseStream(); + + function push(d: ApiEndpointActions[T]) { + try { + const line = JSON.stringify(d); + stream.write(`${line}${DELIMITER}`); + + // Calling .flush() on a compression stream will + // make zlib return as much output as currently possible. + if (isCompressed) { + stream.flush(); + } + } catch (error) { + logger.error('Could not serialize or stream a message.'); + logger.error(error); + } + } + + function end() { + stream.end(); + } + + const responseWithHeaders = { + body: stream, + ...(isCompressed + ? { + headers: { + 'content-encoding': 'gzip', + }, + } + : {}), + }; + + return { DELIMITER, end, push, responseWithHeaders, stream }; +} diff --git a/x-pack/plugins/aiops/server/plugin.ts b/x-pack/plugins/aiops/server/plugin.ts index c6b1b8b22a1873..3743d32e3a081c 100755 --- a/x-pack/plugins/aiops/server/plugin.ts +++ b/x-pack/plugins/aiops/server/plugin.ts @@ -6,23 +6,38 @@ */ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server'; +import type { DataRequestHandlerContext } from '@kbn/data-plugin/server'; -import { AiopsPluginSetup, AiopsPluginStart } from './types'; -import { defineRoutes } from './routes'; +import { AIOPS_ENABLED } from '../common'; -export class AiopsPlugin implements Plugin { +import { + AiopsPluginSetup, + AiopsPluginStart, + AiopsPluginSetupDeps, + AiopsPluginStartDeps, +} from './types'; +import { defineExampleStreamRoute, defineExplainLogRateSpikesRoute } from './routes'; + +export class AiopsPlugin + implements Plugin +{ private readonly logger: Logger; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } - public setup(core: CoreSetup) { + public setup(core: CoreSetup, deps: AiopsPluginSetupDeps) { this.logger.debug('aiops: Setup'); - const router = core.http.createRouter(); + const router = core.http.createRouter(); // Register server side APIs - defineRoutes(router, this.logger); + if (AIOPS_ENABLED) { + core.getStartServices().then(([_, depsStart]) => { + defineExampleStreamRoute(router, this.logger); + defineExplainLogRateSpikesRoute(router, this.logger); + }); + } return {}; } diff --git a/x-pack/plugins/aiops/server/routes/example_stream.ts b/x-pack/plugins/aiops/server/routes/example_stream.ts new file mode 100644 index 00000000000000..38ca28ce6f176a --- /dev/null +++ b/x-pack/plugins/aiops/server/routes/example_stream.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IRouter, Logger } from '@kbn/core/server'; + +import { + aiopsExampleStreamSchema, + updateProgressAction, + addToEntityAction, + deleteEntityAction, +} from '../../common/api/example_stream'; +import { API_ENDPOINT } from '../../common/api'; + +import { streamFactory } from '../lib/stream_factory'; + +export const defineExampleStreamRoute = (router: IRouter, logger: Logger) => { + router.post( + { + path: API_ENDPOINT.EXAMPLE_STREAM, + validate: { + body: aiopsExampleStreamSchema, + }, + }, + async (context, request, response) => { + const maxTimeoutMs = request.body.timeout ?? 250; + const simulateError = request.body.simulateErrors ?? false; + + let shouldStop = false; + request.events.aborted$.subscribe(() => { + shouldStop = true; + }); + request.events.completed$.subscribe(() => { + shouldStop = true; + }); + + const { DELIMITER, end, push, responseWithHeaders, stream } = streamFactory< + typeof API_ENDPOINT.EXAMPLE_STREAM + >(logger, request.headers); + + const entities = [ + 'kimchy', + 's1monw', + 'martijnvg', + 'jasontedor', + 'nik9000', + 'javanna', + 'rjernst', + 'jrodewig', + ]; + + const actions = [...Array(19).fill('add'), 'delete']; + + if (simulateError) { + actions.push('server-only-error'); + actions.push('server-to-client-error'); + actions.push('client-error'); + } + + let progress = 0; + + async function pushStreamUpdate() { + setTimeout(() => { + try { + progress++; + + if (progress > 100 || shouldStop) { + end(); + return; + } + + push(updateProgressAction(progress)); + + const randomEntity = entities[Math.floor(Math.random() * entities.length)]; + const randomAction = actions[Math.floor(Math.random() * actions.length)]; + + if (randomAction === 'add') { + const randomCommits = Math.floor(Math.random() * 100); + push(addToEntityAction(randomEntity, randomCommits)); + } else if (randomAction === 'delete') { + push(deleteEntityAction(randomEntity)); + } else if (randomAction === 'server-to-client-error') { + // Throw an error. It should not crash Kibana! + throw new Error('There was a (simulated) server side error!'); + } else if (randomAction === 'client-error') { + // Return not properly encoded JSON to the client. + stream.push(`{body:'Not valid JSON${DELIMITER}`); + } + + pushStreamUpdate(); + } catch (error) { + stream.push( + `${JSON.stringify({ type: 'error', payload: error.toString() })}${DELIMITER}` + ); + end(); + } + }, Math.floor(Math.random() * maxTimeoutMs)); + } + + // do not call this using `await` so it will run asynchronously while we return the stream already. + pushStreamUpdate(); + + return response.ok(responseWithHeaders); + } + ); +}; diff --git a/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts b/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts new file mode 100644 index 00000000000000..f8aeb06435b761 --- /dev/null +++ b/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstValueFrom } from 'rxjs'; + +import type { IRouter, Logger } from '@kbn/core/server'; +import type { DataRequestHandlerContext, IEsSearchRequest } from '@kbn/data-plugin/server'; + +import { + aiopsExplainLogRateSpikesSchema, + addFieldsAction, +} from '../../common/api/explain_log_rate_spikes'; +import { API_ENDPOINT } from '../../common/api'; + +import { streamFactory } from '../lib/stream_factory'; + +export const defineExplainLogRateSpikesRoute = ( + router: IRouter, + logger: Logger +) => { + router.post( + { + path: API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES, + validate: { + body: aiopsExplainLogRateSpikesSchema, + }, + }, + async (context, request, response) => { + const index = request.body.index; + + const controller = new AbortController(); + + let shouldStop = false; + request.events.aborted$.subscribe(() => { + shouldStop = true; + controller.abort(); + }); + request.events.completed$.subscribe(() => { + shouldStop = true; + controller.abort(); + }); + + const search = await context.search; + const res = await firstValueFrom( + search.search( + { + params: { + index, + body: { size: 1 }, + }, + } as IEsSearchRequest, + { abortSignal: controller.signal } + ) + ); + + const doc = res.rawResponse.hits.hits.pop(); + const fields = Object.keys(doc?._source ?? {}); + + const { end, push, responseWithHeaders } = streamFactory< + typeof API_ENDPOINT.EXPLAIN_LOG_RATE_SPIKES + >(logger, request.headers); + + async function pushField() { + setTimeout(() => { + if (shouldStop) { + end(); + return; + } + + const field = fields.pop(); + + if (field !== undefined) { + push(addFieldsAction([field])); + pushField(); + } else { + end(); + } + }, Math.random() * 1000); + } + + pushField(); + + return response.ok(responseWithHeaders); + } + ); +}; diff --git a/x-pack/plugins/aiops/server/routes/index.ts b/x-pack/plugins/aiops/server/routes/index.ts index e87c27e2af81e3..d69ef6cc7df09a 100755 --- a/x-pack/plugins/aiops/server/routes/index.ts +++ b/x-pack/plugins/aiops/server/routes/index.ts @@ -5,125 +5,5 @@ * 2.0. */ -import { Readable } from 'stream'; - -import type { IRouter, Logger } from '@kbn/core/server'; - -import { AIOPS_ENABLED } from '../../common'; -import type { ApiAction } from '../../common/api/example_stream'; -import { - aiopsExampleStreamSchema, - updateProgressAction, - addToEntityAction, - deleteEntityAction, -} from '../../common/api/example_stream'; - -// We need this otherwise Kibana server will crash with a 'ERR_METHOD_NOT_IMPLEMENTED' error. -class ResponseStream extends Readable { - _read(): void {} -} - -const delimiter = '\n'; - -export function defineRoutes(router: IRouter, logger: Logger) { - if (AIOPS_ENABLED) { - router.post( - { - path: '/internal/aiops/example_stream', - validate: { - body: aiopsExampleStreamSchema, - }, - }, - async (context, request, response) => { - const maxTimeoutMs = request.body.timeout ?? 250; - const simulateError = request.body.simulateErrors ?? false; - - let shouldStop = false; - request.events.aborted$.subscribe(() => { - shouldStop = true; - }); - request.events.completed$.subscribe(() => { - shouldStop = true; - }); - - const stream = new ResponseStream(); - - function streamPush(d: ApiAction) { - try { - const line = JSON.stringify(d); - stream.push(`${line}${delimiter}`); - } catch (error) { - logger.error('Could not serialize or stream a message.'); - logger.error(error); - } - } - - const entities = [ - 'kimchy', - 's1monw', - 'martijnvg', - 'jasontedor', - 'nik9000', - 'javanna', - 'rjernst', - 'jrodewig', - ]; - - const actions = [...Array(19).fill('add'), 'delete']; - - if (simulateError) { - actions.push('server-only-error'); - actions.push('server-to-client-error'); - actions.push('client-error'); - } - - let progress = 0; - - async function pushStreamUpdate() { - setTimeout(() => { - try { - progress++; - - if (progress > 100 || shouldStop) { - stream.push(null); - return; - } - - streamPush(updateProgressAction(progress)); - - const randomEntity = entities[Math.floor(Math.random() * entities.length)]; - const randomAction = actions[Math.floor(Math.random() * actions.length)]; - - if (randomAction === 'add') { - const randomCommits = Math.floor(Math.random() * 100); - streamPush(addToEntityAction(randomEntity, randomCommits)); - } else if (randomAction === 'delete') { - streamPush(deleteEntityAction(randomEntity)); - } else if (randomAction === 'server-to-client-error') { - // Throw an error. It should not crash Kibana! - throw new Error('There was a (simulated) server side error!'); - } else if (randomAction === 'client-error') { - // Return not properly encoded JSON to the client. - stream.push(`{body:'Not valid JSON${delimiter}`); - } - - pushStreamUpdate(); - } catch (error) { - stream.push( - `${JSON.stringify({ type: 'error', payload: error.toString() })}${delimiter}` - ); - stream.push(null); - } - }, Math.floor(Math.random() * maxTimeoutMs)); - } - - // do not call this using `await` so it will run asynchronously while we return the stream already. - pushStreamUpdate(); - - return response.ok({ - body: stream, - }); - } - ); - } -} +export { defineExampleStreamRoute } from './example_stream'; +export { defineExplainLogRateSpikesRoute } from './explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/server/types.ts b/x-pack/plugins/aiops/server/types.ts index 526e7280e94951..3d27a9625db4c3 100755 --- a/x-pack/plugins/aiops/server/types.ts +++ b/x-pack/plugins/aiops/server/types.ts @@ -5,6 +5,16 @@ * 2.0. */ +import { PluginSetup, PluginStart } from '@kbn/data-plugin/server'; + +export interface AiopsPluginSetupDeps { + data: PluginSetup; +} + +export interface AiopsPluginStartDeps { + data: PluginStart; +} + /** * aiops plugin server setup contract */ diff --git a/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx index bcf0b44814089c..006b3cc67bd5ed 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiButtonIcon, EuiLoadingContent, + EuiLoadingSpinner, } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; @@ -19,7 +20,7 @@ import { KibanaPageTemplateProps, } from '@kbn/kibana-react-plugin/public'; import { enableServiceGroups } from '@kbn/observability-plugin/public'; -import { useFetcher } from '../../../hooks/use_fetcher'; +import { useFetcher, FETCH_STATUS } from '../../../hooks/use_fetcher'; import { ApmPluginStartDeps } from '../../../plugin'; import { useApmRouter } from '../../../hooks/use_apm_router'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; @@ -51,17 +52,29 @@ export function ServiceGroupTemplate({ query: { serviceGroup: serviceGroupId }, } = useAnyOfApmParams('/services', '/service-map'); - const { data } = useFetcher((callApmApi) => { - if (serviceGroupId) { - return callApmApi('GET /internal/apm/service-group', { - params: { query: { serviceGroup: serviceGroupId } }, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { data } = useFetcher( + (callApmApi) => { + if (serviceGroupId) { + return callApmApi('GET /internal/apm/service-group', { + params: { query: { serviceGroup: serviceGroupId } }, + }); + } + }, + [serviceGroupId] + ); + + const { data: serviceGroupsData, status: serviceGroupsStatus } = useFetcher( + (callApmApi) => { + if (!serviceGroupId && isServiceGroupsEnabled) { + return callApmApi('GET /internal/apm/service-groups'); + } + }, + [serviceGroupId, isServiceGroupsEnabled] + ); const serviceGroupName = data?.serviceGroup.groupName; const loadingServiceGroupName = !!serviceGroupId && !serviceGroupName; + const hasServiceGroups = !!serviceGroupsData?.serviceGroups.length; const serviceGroupsLink = router.link('/service-groups', { query: { ...query, serviceGroup: '' }, }); @@ -74,15 +87,22 @@ export function ServiceGroupTemplate({ justifyContent="flexStart" responsive={false} > - - - + {serviceGroupsStatus === FETCH_STATUS.LOADING && ( + + + + )} + {(serviceGroupId || hasServiceGroups) && ( + + + + )} {loadingServiceGroupName ? ( diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap index 7693388acd319a..ccf0983781e29f 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap +++ b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap @@ -10,6 +10,7 @@ exports[` can navigate Autoplay Settings 1`] = ` aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; will-change: transform, opacity; z-index: 2000;" tabindex="0" @@ -108,6 +109,7 @@ exports[` can navigate Autoplay Settings 2`] = ` aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top euiPopover__panel-isOpen" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; z-index: 2000;" tabindex="0" @@ -359,6 +361,7 @@ exports[` can navigate Toolbar Settings, closes when activated 1`] = aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; will-change: transform, opacity; z-index: 2000;" tabindex="0" @@ -457,6 +460,7 @@ exports[` can navigate Toolbar Settings, closes when activated 2`] = aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top euiPopover__panel-isOpen" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; z-index: 2000;" tabindex="0" @@ -631,4 +635,4 @@ exports[` can navigate Toolbar Settings, closes when activated 2`] = `; -exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; +exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index bb1892525f8e05..df9a7b0e24fd7c 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -7,7 +7,15 @@ import * as rt from 'io-ts'; -import { ActionResult, ActionType } from '@kbn/actions-plugin/common'; +import type { ActionType } from '@kbn/actions-plugin/common'; +/** + * ActionResult type from the common folder is outdated. + * The type from server is not exported properly so we + * disable the linting for the moment + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { ActionResult } from '@kbn/actions-plugin/server/types'; import { JiraFieldsRT } from './jira'; import { ResilientFieldsRT } from './resilient'; import { ServiceNowITSMFieldsRT } from './servicenow_itsm'; diff --git a/x-pack/plugins/cases/public/common/mock/connectors.ts b/x-pack/plugins/cases/public/common/mock/connectors.ts index 01afbbee118a87..d186b68053e7f7 100644 --- a/x-pack/plugins/cases/public/common/mock/connectors.ts +++ b/x-pack/plugins/cases/public/common/mock/connectors.ts @@ -16,6 +16,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance1.service-now.com', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'resilient-2', @@ -26,6 +27,7 @@ export const connectorsMock: ActionConnector[] = [ orgId: '201', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'jira-1', @@ -35,6 +37,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance.atlassian.ne', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'servicenow-sir', @@ -44,6 +47,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance1.service-now.com', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'servicenow-uses-table-api', @@ -54,6 +58,7 @@ export const connectorsMock: ActionConnector[] = [ usesTableApi: true, }, isPreconfigured: false, + isDeprecated: true, }, ]; diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 764a51443b0e35..b09eecbb31f4f5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -77,6 +77,7 @@ describe('ExternalServiceColumn ', () => { name: 'None', config: {}, isPreconfigured: false, + isDeprecated: false, }, ]} /> diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 63fc2e2695a3a4..e8093325c1e09a 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -249,6 +249,7 @@ describe('ConnectorsDropdown', () => { name: 'None', config: {}, isPreconfigured: false, + isDeprecated: false, }, ]} />, @@ -269,4 +270,26 @@ describe('ConnectorsDropdown', () => { ); expect(tooltips[0]).toBeInTheDocument(); }); + + test('it shows the deprecated tooltip when the connector is deprecated by configuration', () => { + const connector = connectors[0]; + render( + , + { wrapper: ({ children }) => {children} } + ); + + const tooltips = screen.getAllByText( + 'This connector is deprecated. Update it, or create a new one.' + ); + expect(tooltips[0]).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/connectors/mock.ts b/x-pack/plugins/cases/public/components/connectors/mock.ts index 2eb512af0f2ef7..ba29319a8926c3 100644 --- a/x-pack/plugins/cases/public/components/connectors/mock.ts +++ b/x-pack/plugins/cases/public/components/connectors/mock.ts @@ -13,6 +13,7 @@ export const connector = { actionTypeId: '.jira', config: {}, isPreconfigured: false, + isDeprecated: false, }; export const swimlaneConnector = { @@ -29,6 +30,7 @@ export const swimlaneConnector = { }, }, isPreconfigured: false, + isDeprecated: false, }; export const issues = [ diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index cfc16f1fb6e8bc..e2f4a683772c79 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -135,18 +135,18 @@ describe('ServiceNowITSM Fields', () => { ); }); - it('shows the deprecated callout when the connector uses the table API', async () => { - const tableApiConnector = { ...connector, config: { usesTableApi: true } }; + it('shows the deprecated callout if the connector is deprecated', async () => { + const tableApiConnector = { ...connector, isDeprecated: true }; render(); expect(screen.getByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector does not uses the table API', async () => { + it('does not show the deprecated callout when the connector is not deprecated', async () => { render(); expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector is preconfigured', async () => { + it('does not show the deprecated callout when the connector is preconfigured and not deprecated', async () => { render( { expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the config of the connector is undefined', async () => { + it('shows the deprecated callout when the connector is preconfigured and deprecated', async () => { render( - // @ts-expect-error - + ); - expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); it('should hide subcategory if selecting a category without subcategories', async () => { diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index f366cc95ff77ac..2dae544ec274c1 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -16,7 +16,6 @@ import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Fields, Choice } from './types'; import { choicesToEuiOptions } from './helpers'; -import { connectorValidator } from './validator'; import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; @@ -44,7 +43,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); - const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + const showConnectorWarning = connector.isDeprecated; const categoryOptions = useMemo( () => choicesToEuiOptions(choices.category), diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index a2c61ac78be0bb..1b06e0cfdce816 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -169,18 +169,18 @@ describe('ServiceNowSIR Fields', () => { ]); }); - test('it shows the deprecated callout when the connector uses the table API', async () => { - const tableApiConnector = { ...connector, config: { usesTableApi: true } }; + test('shows the deprecated callout if the connector is deprecated', async () => { + const tableApiConnector = { ...connector, isDeprecated: true }; render(); expect(screen.getByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); - test('it does not show the deprecated callout when the connector does not uses the table API', async () => { + test('does not show the deprecated callout when the connector is not deprecated', async () => { render(); expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector is preconfigured', async () => { + it('does not show the deprecated callout when the connector is preconfigured and not deprecated', async () => { render( { expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the config of the connector is undefined', async () => { + it('shows the deprecated callout when the connector is preconfigured and deprecated', async () => { render( - // @ts-expect-error - + ); - expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); test('it should hide subcategory if selecting a category without subcategories', async () => { diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 99bbe8aabaedae..78f17a1d4215a4 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -17,7 +17,6 @@ import { Choice, Fields } from './types'; import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; -import { connectorValidator } from './validator'; import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; @@ -43,7 +42,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); - const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + const showConnectorWarning = connector.isDeprecated; const onChangeCb = useCallback( ( diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx index 950b17d6f784fc..9a4e19d126bba3 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx @@ -29,6 +29,7 @@ const connector = { actionTypeId: '.servicenow', name: 'ServiceNow', isPreconfigured: false, + isDeprecated: false, config: { apiUrl: 'https://dev94428.service-now.com/', }, diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts deleted file mode 100644 index ab21a6b5c779cb..00000000000000 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { connector } from '../mock'; -import { connectorValidator } from './validator'; - -describe('ServiceNow validator', () => { - describe('connectorValidator', () => { - test('it returns an error message if the connector uses the table API', () => { - const invalidConnector = { - ...connector, - config: { - ...connector.config, - usesTableApi: true, - }, - }; - - expect(connectorValidator(invalidConnector)).toEqual({ message: 'Deprecated connector' }); - }); - - test('it does not return an error message if the connector does not uses the table API', () => { - const invalidConnector = { - ...connector, - config: { - ...connector.config, - usesTableApi: false, - }, - }; - - expect(connectorValidator(invalidConnector)).toBeFalsy(); - }); - - test('it does not return an error message if the config of the connector is undefined', () => { - const { config, ...invalidConnector } = connector; - - // @ts-expect-error - expect(connectorValidator(invalidConnector)).toBeFalsy(); - }); - - test('it does not return an error message if the config of the connector is preconfigured', () => { - expect(connectorValidator({ ...connector, isPreconfigured: true })).toBeFalsy(); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts deleted file mode 100644 index fed29007155277..00000000000000 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ValidationConfig } from '../../../common/shared_imports'; -import { CaseActionConnector } from '../../types'; - -/** - * The user can not create cases with connectors that use the table API - */ - -export const connectorValidator = ( - connector: CaseActionConnector -): ReturnType => { - /** - * It is not possible to know if a preconfigured connector - * is deprecated or not as the config property of a - * preconfigured connector is not returned by the - * actions framework - */ - - if (connector.isPreconfigured || connector.config == null) { - return; - } - - if (connector.config?.usesTableApi) { - return { - message: 'Deprecated connector', - }; - } -}; diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts index c8cb142232972b..a179091282991a 100644 --- a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts @@ -56,5 +56,26 @@ describe('Swimlane validator', () => { expect(connectorValidator(invalidConnector)).toBe(undefined); } ); + + test('it does not return an error message if the config is undefined', () => { + const invalidConnector = { + ...connector, + config: undefined, + }; + + expect(connectorValidator(invalidConnector)).toBe(undefined); + }); + + test('it returns an error message if the mappings are undefined', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + mappings: undefined, + }, + }; + + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' }); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts index 90d9946d4adb85..d3c94d0150bbe8 100644 --- a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts @@ -28,10 +28,21 @@ export const isAnyRequiredFieldNotSet = (mapping: Record | unde export const connectorValidator = ( connector: CaseActionConnector ): ReturnType => { - const { - config: { mappings, connectorType }, - } = connector; - if (connectorType === SwimlaneConnectorType.Alerts || isAnyRequiredFieldNotSet(mappings)) { + const config = connector.config as + | { + mappings: Record | undefined; + connectorType: string; + } + | undefined; + + if (config == null) { + return; + } + + if ( + config.connectorType === SwimlaneConnectorType.Alerts || + isAnyRequiredFieldNotSet(config.mappings) + ) { return { message: 'Invalid connector', }; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx index 7d7ce5d6384892..3f2b3c24206297 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx @@ -394,10 +394,7 @@ export class SavedObjectFinderUi extends React.Component< } > - +
{this.props.showFilter && ( ( { + const connector = { + id: 'test', + actionTypeId: '.webhook', + name: 'Test', + config: { usesTableApi: false }, + secrets: {}, + isPreconfigured: false, + isDeprecated: false, + }; + describe('getConnectorIcon', () => { const { createMockActionTypeModel } = actionTypeRegistryMock; const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); @@ -38,60 +48,40 @@ describe('Utils', () => { }); }); - describe('isDeprecatedConnector', () => { - const connector = { - id: 'test', - actionTypeId: '.webhook', - name: 'Test', - config: { usesTableApi: false }, - secrets: {}, - isPreconfigured: false, - }; - - it('returns false if the connector is not defined', () => { - expect(isDeprecatedConnector()).toBe(false); + describe('connectorDeprecationValidator', () => { + it('returns undefined if the connector is not deprecated', () => { + expect(connectorDeprecationValidator(connector)).toBe(undefined); }); - it('returns false if the connector is not ITSM or SecOps', () => { - expect(isDeprecatedConnector(connector)).toBe(false); + it('returns a deprecation message if the connector is deprecated', () => { + expect(connectorDeprecationValidator({ ...connector, isDeprecated: true })).toEqual({ + message: 'Deprecated connector', + }); }); + }); - it('returns false if the connector is .servicenow and the usesTableApi=false', () => { - expect(isDeprecatedConnector({ ...connector, actionTypeId: '.servicenow' })).toBe(false); + describe('isDeprecatedConnector', () => { + it('returns false if the connector is not defined', () => { + expect(isDeprecatedConnector()).toBe(false); }); - it('returns false if the connector is .servicenow-sir and the usesTableApi=false', () => { - expect(isDeprecatedConnector({ ...connector, actionTypeId: '.servicenow-sir' })).toBe(false); + it('returns false if the connector is marked as deprecated', () => { + expect(isDeprecatedConnector({ ...connector, isDeprecated: false })).toBe(false); }); - it('returns true if the connector is .servicenow and the usesTableApi=true', () => { - expect( - isDeprecatedConnector({ - ...connector, - actionTypeId: '.servicenow', - config: { usesTableApi: true }, - }) - ).toBe(true); + it('returns true if the connector is marked as deprecated', () => { + expect(isDeprecatedConnector({ ...connector, isDeprecated: true })).toBe(true); }); - it('returns true if the connector is .servicenow-sir and the usesTableApi=true', () => { + it('returns true if the connector is marked as deprecated (preconfigured connector)', () => { expect( - isDeprecatedConnector({ - ...connector, - actionTypeId: '.servicenow-sir', - config: { usesTableApi: true }, - }) + isDeprecatedConnector({ ...connector, isDeprecated: true, isPreconfigured: true }) ).toBe(true); }); - it('returns false if the connector preconfigured', () => { - expect(isDeprecatedConnector({ ...connector, isPreconfigured: true })).toBe(false); - }); - - it('returns false if the config is undefined', () => { + it('returns false if the connector is not marked as deprecated (preconfigured connector)', () => { expect( - // @ts-expect-error - isDeprecatedConnector({ ...connector, config: undefined }) + isDeprecatedConnector({ ...connector, isDeprecated: false, isPreconfigured: true }) ).toBe(false); }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 34ebffb4eacb4b..403f55574f9a69 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -10,7 +10,6 @@ import { ConnectorTypes } from '../../common/api'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; import { CasesPluginStart } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; -import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; export const getConnectorById = ( @@ -23,8 +22,16 @@ const validators: Record< (connector: CaseActionConnector) => ReturnType > = { [ConnectorTypes.swimlane]: swimlaneConnectorValidator, - [ConnectorTypes.serviceNowITSM]: servicenowConnectorValidator, - [ConnectorTypes.serviceNowSIR]: servicenowConnectorValidator, +}; + +export const connectorDeprecationValidator = ( + connector: CaseActionConnector +): ReturnType => { + if (connector.isDeprecated) { + return { + message: 'Deprecated connector', + }; + } }; export const getConnectorsFormValidators = ({ @@ -36,6 +43,14 @@ export const getConnectorsFormValidators = ({ }): FieldConfig => ({ ...config, validations: [ + { + validator: ({ value: connectorId }) => { + const connector = getConnectorById(connectorId as string, connectors); + if (connector != null) { + return connectorDeprecationValidator(connector); + } + }, + }, { validator: ({ value: connectorId }) => { const connector = getConnectorById(connectorId as string, connectors); @@ -72,28 +87,6 @@ export const getConnectorIcon = ( return emptyResponse; }; -// TODO: Remove when the applications are certified export const isDeprecatedConnector = (connector?: CaseActionConnector): boolean => { - /** - * It is not possible to know if a preconfigured connector - * is deprecated or not as the config property of a - * preconfigured connector is not returned by the - * actions framework - */ - if (connector == null || connector.config == null || connector.isPreconfigured) { - return false; - } - - if (connector.actionTypeId === '.servicenow' || connector.actionTypeId === '.servicenow-sir') { - /** - * Connector's prior to the Elastic ServiceNow application - * use the Table API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_TableAPI) - * Connectors after the Elastic ServiceNow application use the - * Import Set API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_ImportSetAPI) - * A ServiceNow connector is considered deprecated if it uses the Table API. - */ - return !!connector.config.usesTableApi; - } - - return false; + return connector?.isDeprecated ?? false; }; diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index 4832ffe5b2eafd..baf32fd30d74bb 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -521,6 +521,7 @@ describe('utils', () => { apiUrl: 'https://elastic.jira.com', }, isPreconfigured: false, + isDeprecated: false, }; it('creates an external incident', async () => { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx index a6b8f3b8634018..9cc87d98e54f84 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx @@ -21,14 +21,23 @@ import { TestProvider } from '../../../test/test_provider'; const chance = new Chance(); -const getFakeFindingsByResource = (): CspFindingsByResource => ({ - resource_id: chance.guid(), - cis_sections: [chance.word(), chance.word()], - failed_findings: { - total: chance.integer(), - normalized: chance.integer({ min: 0, max: 1 }), - }, -}); +const getFakeFindingsByResource = (): CspFindingsByResource => { + const count = chance.integer(); + const total = chance.integer() + count + 1; + const normalized = count / total; + + return { + resource_id: chance.guid(), + resource_name: chance.word(), + resource_subtype: chance.word(), + cis_sections: [chance.word(), chance.word()], + failed_findings: { + count, + normalized, + total_findings: total, + }, + }; +}; type TableProps = PropsOf; @@ -74,8 +83,11 @@ describe('', () => { ); expect(row).toBeInTheDocument(); expect(within(row).getByText(item.resource_id)).toBeInTheDocument(); + if (item.resource_name) expect(within(row).getByText(item.resource_name)).toBeInTheDocument(); + if (item.resource_subtype) + expect(within(row).getByText(item.resource_subtype)).toBeInTheDocument(); expect(within(row).getByText(item.cis_sections.join(', '))).toBeInTheDocument(); - expect(within(row).getByText(formatNumber(item.failed_findings.total))).toBeInTheDocument(); + expect(within(row).getByText(formatNumber(item.failed_findings.count))).toBeInTheDocument(); expect( within(row).getByText(new RegExp(numeral(item.failed_findings.normalized).format('0%'))) ).toBeInTheDocument(); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx index 2e96306ad3a694..80da9222258934 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx @@ -9,12 +9,12 @@ import { EuiEmptyPrompt, EuiBasicTable, EuiTextColor, - EuiFlexGroup, - EuiFlexItem, type EuiTableFieldDataColumnType, type CriteriaWithPagination, type Pagination, + EuiToolTip, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import numeral from '@elastic/numeral'; import { Link, generatePath } from 'react-router-dom'; @@ -81,6 +81,26 @@ const columns: Array> = [ ), }, + { + field: 'resource_subtype', + truncateText: true, + name: ( + + ), + }, + { + field: 'resource_name', + truncateText: true, + name: ( + + ), + }, { field: 'cis_sections', truncateText: true, @@ -102,14 +122,22 @@ const columns: Array> = [ /> ), render: (failedFindings: CspFindingsByResource['failed_findings']) => ( - - - {formatNumber(failedFindings.total)} - - - ({numeral(failedFindings.normalized).format('0%')}) - - + + <> + + {formatNumber(failedFindings.count)} + + ({numeral(failedFindings.normalized).format('0%')}) + + ), }, ]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts index 880b2be868e6f4..e2da77c8ba2a21 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts @@ -14,7 +14,7 @@ import { showErrorToast } from '../latest_findings/use_latest_findings'; import type { FindingsBaseEsQuery, FindingsQueryResult } from '../types'; // a large number to probably get all the buckets -const MAX_BUCKETS = 60 * 1000; +const MAX_BUCKETS = 1000 * 1000; interface UseResourceFindingsOptions extends FindingsBaseEsQuery { from: NonNullable; @@ -43,6 +43,8 @@ interface FindingsByResourceAggs { interface FindingsAggBucket extends estypes.AggregationsStringRareTermsBucketKeys { failed_findings: estypes.AggregationsMultiBucketBase; + name: estypes.AggregationsMultiBucketAggregateBase; + subtype: estypes.AggregationsMultiBucketAggregateBase; cis_sections: estypes.AggregationsMultiBucketAggregateBase; } @@ -57,10 +59,16 @@ export const getFindingsByResourceAggQuery = ({ query, size: 0, aggs: { - resource_total: { cardinality: { field: 'resource.id.keyword' } }, + resource_total: { cardinality: { field: 'resource.id' } }, resources: { - terms: { field: 'resource.id.keyword', size: MAX_BUCKETS }, + terms: { field: 'resource.id', size: MAX_BUCKETS }, aggs: { + name: { + terms: { field: 'resource.name', size: 1 }, + }, + subtype: { + terms: { field: 'resource.sub_type', size: 1 }, + }, cis_sections: { terms: { field: 'rule.section.keyword' }, }, @@ -117,16 +125,24 @@ export const useFindingsByResource = ({ index, query, from, size }: UseResourceF ); }; -const createFindingsByResource = (bucket: FindingsAggBucket) => { - if (!Array.isArray(bucket.cis_sections.buckets)) +const createFindingsByResource = (resource: FindingsAggBucket) => { + if ( + !Array.isArray(resource.cis_sections.buckets) || + !Array.isArray(resource.name.buckets) || + !Array.isArray(resource.subtype.buckets) + ) throw new Error('expected buckets to be an array'); return { - resource_id: bucket.key, - cis_sections: bucket.cis_sections.buckets.map((v) => v.key), + resource_id: resource.key, + resource_name: resource.name.buckets.map((v) => v.key).at(0), + resource_subtype: resource.subtype.buckets.map((v) => v.key).at(0), + cis_sections: resource.cis_sections.buckets.map((v) => v.key), failed_findings: { - total: bucket.failed_findings.doc_count, - normalized: bucket.doc_count > 0 ? bucket.failed_findings.doc_count / bucket.doc_count : 0, + count: resource.failed_findings.doc_count, + normalized: + resource.doc_count > 0 ? resource.failed_findings.doc_count / resource.doc_count : 0, + total_findings: resource.doc_count, }, }; }; diff --git a/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts b/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts index 9ebe4c3cf4038e..57305fd2df7c42 100644 --- a/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts +++ b/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts @@ -50,20 +50,32 @@ export const latestFindingsMapping: MappingTypeMapping = { properties: { type: { type: 'keyword', - ignore_above: 256, + ignore_above: 1024, }, id: { - type: 'text', + type: 'keyword', + ignore_above: 1024, + fields: { + text: { + type: 'text', + }, + }, }, name: { - type: 'text', + type: 'keyword', + ignore_above: 1024, + fields: { + text: { + type: 'text', + }, + }, }, sub_type: { - type: 'text', + ignore_above: 1024, + type: 'keyword', fields: { - keyword: { - ignore_above: 1024, - type: 'keyword', + text: { + type: 'text', }, }, }, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts index 0f197f4a13ddd6..0b3176154c5ff0 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts @@ -10,7 +10,7 @@ import { SerializableRecord } from '@kbn/utility-types'; import { Filter } from '@kbn/es-query'; import { RefreshInterval, TimeRange } from '@kbn/data-plugin/common'; import { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/common'; -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { Dictionary, isRisonSerializationRequired } from '../../common/util/url_state'; import { SearchQueryLanguage } from '../types/combined_query'; @@ -124,7 +124,7 @@ export class IndexDataVisualizerLocatorDefinition sortField?: string; showDistributions?: number; } = {}; - const queryState: QueryState = {}; + const queryState: GlobalQueryStateFromUrl = {}; if (query) { appState.searchQuery = query.searchQuery; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index b037a5aed62171..1d38cb584fa431 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -64,7 +64,9 @@ class DocLinks { public workplaceSearchApiKeys: string; public workplaceSearchBox: string; public workplaceSearchConfluenceCloud: string; + public workplaceSearchConfluenceCloudConnectorPackage: string; public workplaceSearchConfluenceServer: string; + public workplaceSearchCustomConnectorPackage: string; public workplaceSearchCustomSources: string; public workplaceSearchCustomSourcePermissions: string; public workplaceSearchDocumentPermissions: string; @@ -78,7 +80,9 @@ class DocLinks { public workplaceSearchIndexingSchedule: string; public workplaceSearchJiraCloud: string; public workplaceSearchJiraServer: string; + public workplaceSearchNetworkDrive: string; public workplaceSearchOneDrive: string; + public workplaceSearchOutlook: string; public workplaceSearchPermissions: string; public workplaceSearchSalesforce: string; public workplaceSearchSecurity: string; @@ -87,7 +91,9 @@ class DocLinks { public workplaceSearchSharePointServer: string; public workplaceSearchSlack: string; public workplaceSearchSynch: string; + public workplaceSearchTeams: string; public workplaceSearchZendesk: string; + public workplaceSearchZoom: string; constructor() { this.appSearchApis = ''; @@ -146,7 +152,9 @@ class DocLinks { this.workplaceSearchApiKeys = ''; this.workplaceSearchBox = ''; this.workplaceSearchConfluenceCloud = ''; + this.workplaceSearchConfluenceCloudConnectorPackage = ''; this.workplaceSearchConfluenceServer = ''; + this.workplaceSearchCustomConnectorPackage = ''; this.workplaceSearchCustomSources = ''; this.workplaceSearchCustomSourcePermissions = ''; this.workplaceSearchDocumentPermissions = ''; @@ -160,7 +168,9 @@ class DocLinks { this.workplaceSearchIndexingSchedule = ''; this.workplaceSearchJiraCloud = ''; this.workplaceSearchJiraServer = ''; + this.workplaceSearchNetworkDrive = ''; this.workplaceSearchOneDrive = ''; + this.workplaceSearchOutlook = ''; this.workplaceSearchPermissions = ''; this.workplaceSearchSalesforce = ''; this.workplaceSearchSecurity = ''; @@ -169,7 +179,9 @@ class DocLinks { this.workplaceSearchSharePointServer = ''; this.workplaceSearchSlack = ''; this.workplaceSearchSynch = ''; + this.workplaceSearchTeams = ''; this.workplaceSearchZendesk = ''; + this.workplaceSearchZoom = ''; } public setDocLinks(docLinks: DocLinksStart): void { @@ -230,7 +242,11 @@ class DocLinks { this.workplaceSearchApiKeys = docLinks.links.workplaceSearch.apiKeys; this.workplaceSearchBox = docLinks.links.workplaceSearch.box; this.workplaceSearchConfluenceCloud = docLinks.links.workplaceSearch.confluenceCloud; + this.workplaceSearchConfluenceCloudConnectorPackage = + docLinks.links.workplaceSearch.confluenceCloudConnectorPackage; this.workplaceSearchConfluenceServer = docLinks.links.workplaceSearch.confluenceServer; + this.workplaceSearchCustomConnectorPackage = + docLinks.links.workplaceSearch.customConnectorPackage; this.workplaceSearchCustomSources = docLinks.links.workplaceSearch.customSources; this.workplaceSearchCustomSourcePermissions = docLinks.links.workplaceSearch.customSourcePermissions; @@ -246,7 +262,9 @@ class DocLinks { this.workplaceSearchIndexingSchedule = docLinks.links.workplaceSearch.indexingSchedule; this.workplaceSearchJiraCloud = docLinks.links.workplaceSearch.jiraCloud; this.workplaceSearchJiraServer = docLinks.links.workplaceSearch.jiraServer; + this.workplaceSearchNetworkDrive = docLinks.links.workplaceSearch.networkDrive; this.workplaceSearchOneDrive = docLinks.links.workplaceSearch.oneDrive; + this.workplaceSearchOutlook = docLinks.links.workplaceSearch.outlook; this.workplaceSearchPermissions = docLinks.links.workplaceSearch.permissions; this.workplaceSearchSalesforce = docLinks.links.workplaceSearch.salesforce; this.workplaceSearchSecurity = docLinks.links.workplaceSearch.security; @@ -255,7 +273,9 @@ class DocLinks { this.workplaceSearchSharePointServer = docLinks.links.workplaceSearch.sharePointServer; this.workplaceSearchSlack = docLinks.links.workplaceSearch.slack; this.workplaceSearchSynch = docLinks.links.workplaceSearch.synch; + this.workplaceSearchTeams = docLinks.links.workplaceSearch.teams; this.workplaceSearchZendesk = docLinks.links.workplaceSearch.zendesk; + this.workplaceSearchZoom = docLinks.links.workplaceSearch.zoom; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index db3da678e1e00a..181cd8b7c9a736 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -25,7 +25,7 @@ export const staticGenericExternalSourceData: SourceDataItem = { isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchCustomConnectorPackage, applicationPortalUrl: '', }, objTypes: [], @@ -107,7 +107,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: true, - documentationUrl: docLinks.workplaceSearchConfluenceCloud, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchConfluenceCloudConnectorPackage, applicationPortalUrl: 'https://developer.atlassian.com/console/myapps/', }, objTypes: [ @@ -387,7 +387,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchNetworkDrive, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-network-drive-connector', }, @@ -433,7 +433,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchOutlook, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-outlook-connector', }, @@ -649,7 +649,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchTeams, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-teams-connector', }, @@ -691,7 +691,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchZoom, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-zoom-connector', }, diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg deleted file mode 100644 index cc07fbbc508776..00000000000000 --- a/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index d2d3b5d4d68290..140e36ba15555f 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -338,24 +338,6 @@ const workplaceSearchIntegrations: WorkplaceSearchIntegration[] = [ categories: ['enterprise_search', 'communications', 'productivity'], uiInternalPath: '/app/enterprise_search/workplace_search/sources/add/zoom', }, - { - id: 'custom_api_source', - title: i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName', - { - defaultMessage: 'Custom API Source', - } - ), - description: i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription', - { - defaultMessage: - 'Search over anything by building your own integration with Workplace Search.', - } - ), - categories: ['custom'], - uiInternalPath: '/app/enterprise_search/workplace_search/sources/add/custom', - }, ]; export const registerEnterpriseSearchIntegrations = ( diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts index d74d7656ad58e0..8f47d564c44a2c 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts @@ -664,6 +664,5 @@ export const item: GetInfoResponse['item'] = { github: 'elastic/integrations', }, latestVersion: '0.7.0', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts index 1f4b9e85043a60..8778938443661c 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts @@ -263,6 +263,5 @@ export const item: GetInfoResponse['item'] = { github: 'elastic/security-external-integrations', }, latestVersion: '1.2.0', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index dca3fd3ccb6789..ba18b78d5f7686 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3573,9 +3573,6 @@ }, "path": { "type": "string" - }, - "removable": { - "type": "boolean" } }, "required": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index d1a114b35ab6c5..e18fe6b8fc3f84 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2228,8 +2228,6 @@ components: type: string path: type: string - removable: - type: boolean required: - name - title diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml index ec4f18af8a223b..e61c349f3f490b 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml @@ -102,8 +102,6 @@ properties: type: string path: type: string - removable: - type: boolean required: - name - title diff --git a/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts b/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts index 2b93cca3d4e4d2..63397e484a7df4 100644 --- a/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts +++ b/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts @@ -1921,7 +1921,6 @@ export const AWS_PACKAGE = { }, ], latestVersion: '0.5.3', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index d4e8375bbaa5d1..a8a6c34f06f3cd 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -175,6 +175,9 @@ export const agentRouteService = { getUpgradePath: (agentId: string) => AGENT_API_ROUTES.UPGRADE_PATTERN.replace('{agentId}', agentId), getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, + getCurrentUpgradesPath: () => AGENT_API_ROUTES.CURRENT_UPGRADES_PATTERN, + getCancelActionPath: (actionId: string) => + AGENT_API_ROUTES.CANCEL_ACTIONS_PATTERN.replace('{actionId}', actionId), getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, getIncomingDataPath: () => AGENT_API_ROUTES.DATA_PATTERN, diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index b3847ac8c6892b..a26f63eba755bf 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -98,6 +98,7 @@ export interface CurrentUpgrade { complete: boolean; nbAgents: number; nbAgentsAck: number; + version: string; } // Generated from FleetServer schema.json diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index c7951e86d78666..cb5d8f3bb009b6 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -372,7 +372,6 @@ export interface EpmPackageAdditions { title: string; latestVersion: string; assets: AssetsGroupedByServiceByType; - removable?: boolean; notice?: string; keepPoliciesUpToDate?: boolean; } diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 7a8b7b918c1e3f..886730d38f8314 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -89,6 +89,7 @@ export interface PostBulkAgentUpgradeRequest { agents: string[] | string; source_uri?: string; version: string; + rollout_duration_seconds?: number; }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx index 0f719f6a61585a..4a13f117ec6ba5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx @@ -164,7 +164,6 @@ describe('when on the package policy create page', () => { }, ], latestVersion: '1.3.0', - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx index 543747307908e4..ff4c39af799f29 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx @@ -96,7 +96,6 @@ describe('StepConfigurePackage', () => { }, ], latestVersion: '1.3.0', - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx index 3a5050b1b6d065..464f705811ebf6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx @@ -89,7 +89,6 @@ jest.mock('../../../hooks', () => { }, ], latestVersion: version, - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx index 44e87d7fb4e63b..239afe6c7e330f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx @@ -70,7 +70,6 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ { setIsUpgradeModalOpen(false); refreshAgent(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx index a2515b51814ee0..e27c647e25f702 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx @@ -24,7 +24,6 @@ import { AgentUnenrollAgentModal, AgentUpgradeAgentModal, } from '../../components'; -import { useKibanaVersion } from '../../../../hooks'; import type { SelectionMode } from './types'; @@ -48,11 +47,10 @@ export const AgentBulkActions: React.FunctionComponent = ({ selectedAgents, refreshAgents, }) => { - const kibanaVersion = useKibanaVersion(); // Bulk actions menu states const [isMenuOpen, setIsMenuOpen] = useState(false); const closeMenu = () => setIsMenuOpen(false); - const openMenu = () => setIsMenuOpen(true); + const onClickMenu = () => setIsMenuOpen(!isMenuOpen); // Actions states const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false); @@ -150,7 +148,6 @@ export const AgentBulkActions: React.FunctionComponent = ({ {isUpgradeModalOpen && ( { @@ -172,7 +169,7 @@ export const AgentBulkActions: React.FunctionComponent = ({ fill iconType="arrowDown" iconSide="right" - onClick={openMenu} + onClick={onClickMenu} data-test-subj="agentBulkActionsButton" > Promise; +} + +export const CurrentBulkUpgradeCallout: React.FunctionComponent = ({ + currentUpgrade, + abortUpgrade, +}) => { + const { docLinks } = useStartServices(); + const [isAborting, setIsAborting] = useState(false); + const onClickAbortUpgrade = useCallback(async () => { + try { + setIsAborting(true); + await abortUpgrade(currentUpgrade); + } finally { + setIsAborting(false); + } + }, [currentUpgrade, abortUpgrade]); + + return ( + + + +
+ +    + +
+
+ + + + + +
+ + + + ), + }} + /> +
+ ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx new file mode 100644 index 00000000000000..36028c0d2c9b5f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CurrentBulkUpgradeCallout } from './current_bulk_upgrade_callout'; +export type { CurrentBulkUpgradeCalloutProps } from './current_bulk_upgrade_callout'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx index 9e084b07e64d17..f93646eb120abe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx @@ -9,7 +9,7 @@ import { EuiToolTip } from '@elastic/eui'; import { take } from 'lodash'; import React from 'react'; -import { truncateTag } from '../utils'; +import { truncateTag, MAX_TAG_DISPLAY_LENGTH } from '../utils'; interface Props { tags: string[]; @@ -30,7 +30,20 @@ export const Tags: React.FunctionComponent = ({ tags }) => { ) : ( - {tags.map(truncateTag).join(', ')} + + {tags.map((tag, index) => ( + <> + {index > 0 && ', '} + {tag.length > MAX_TAG_DISPLAY_LENGTH ? ( + {tag}} key={tag}> + {truncateTag(tag)} + + ) : ( + {tag} + )} + + ))} + )} ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx new file mode 100644 index 00000000000000..4ab06bfcc8a912 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useCurrentUpgrades } from './use_current_upgrades'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx new file mode 100644 index 00000000000000..cdec2ad667be46 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { sendGetCurrentUpgrades, sendPostCancelAction, useStartServices } from '../../../../hooks'; + +import type { CurrentUpgrade } from '../../../../types'; + +const POLL_INTERVAL = 2 * 60 * 1000; // 2 minutes + +export function useCurrentUpgrades(onAbortSuccess: () => void) { + const [currentUpgrades, setCurrentUpgrades] = useState([]); + const currentTimeoutRef = useRef(); + const isCancelledRef = useRef(false); + const { notifications, overlays } = useStartServices(); + + const refreshUpgrades = useCallback(async () => { + try { + const res = await sendGetCurrentUpgrades(); + if (isCancelledRef.current) { + return; + } + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data'); + } + + setCurrentUpgrades(res.data.items); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.currentUpgrade.fetchRequestError', { + defaultMessage: 'An error happened while fetching current upgrades', + }), + }); + } + }, [notifications.toasts]); + + const abortUpgrade = useCallback( + async (currentUpgrade: CurrentUpgrade) => { + try { + const confirmRes = await overlays.openConfirm( + i18n.translate('xpack.fleet.currentUpgrade.confirmDescription', { + defaultMessage: 'This action will abort upgrade of {nbAgents} agents', + values: { + nbAgents: currentUpgrade.nbAgents - currentUpgrade.nbAgentsAck, + }, + }), + { + title: i18n.translate('xpack.fleet.currentUpgrade.confirmTitle', { + defaultMessage: 'Abort upgrade?', + }), + } + ); + + if (!confirmRes) { + return; + } + await sendPostCancelAction(currentUpgrade.actionId); + await Promise.all([refreshUpgrades(), onAbortSuccess()]); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.currentUpgrade.abortRequestError', { + defaultMessage: 'An error happened while aborting upgrade', + }), + }); + } + }, + [refreshUpgrades, notifications.toasts, overlays, onAbortSuccess] + ); + + // Poll for upgrades + useEffect(() => { + isCancelledRef.current = false; + + async function pollData() { + await refreshUpgrades(); + if (isCancelledRef.current) { + return; + } + currentTimeoutRef.current = setTimeout(() => pollData(), POLL_INTERVAL); + } + + pollData(); + + return () => { + isCancelledRef.current = true; + + if (currentTimeoutRef.current) { + clearTimeout(currentTimeoutRef.current); + } + }; + }, [refreshUpgrades]); + + return { + currentUpgrades, + refreshUpgrades, + abortUpgrade, + }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index be38f7688c7357..bbea3284f72b81 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -46,12 +46,14 @@ import { } from '../components'; import { useFleetServerUnhealthy } from '../hooks/use_fleet_server_unhealthy'; +import { CurrentBulkUpgradeCallout } from './components'; import { AgentTableHeader } from './components/table_header'; import type { SelectionMode } from './components/types'; import { SearchAndFilterBar } from './components/search_and_filter_bar'; import { Tags } from './components/tags'; import { TableRowActions } from './components/table_row_actions'; import { EmptyPrompt } from './components/empty_prompt'; +import { useCurrentUpgrades } from './hooks'; const REFRESH_INTERVAL_MS = 30000; @@ -335,6 +337,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { flyoutContext.openFleetServerFlyout(); }, [flyoutContext]); + // Current upgrades + const { abortUpgrade, currentUpgrades, refreshUpgrades } = useCurrentUpgrades(fetchData); + const columns = [ { field: 'local_metadata.host.hostname', @@ -395,12 +400,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'local_metadata.elastic.agent.version', - width: '120px', + width: '135px', name: i18n.translate('xpack.fleet.agentList.versionTitle', { defaultMessage: 'Version', }), render: (version: string, agent: Agent) => ( - + {safeMetadata(version)} @@ -490,7 +495,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { />
)} - {agentToUpgrade && ( = () => { onClose={() => { setAgentToUpgrade(undefined); fetchData(); + refreshUpgrades(); }} - version={kibanaVersion} /> )} - {isFleetServerUnhealthy && ( <> {cloud?.deploymentUrl ? ( @@ -515,7 +518,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} - + {/* Current upgrades callout */} + {currentUpgrades.map((currentUpgrade) => ( + + + + + ))} {/* Search and filter bar */} = () => { selectionMode={selectionMode} currentQuery={kuery} selectedAgents={selectedAgents} - refreshAgents={() => fetchData()} + refreshAgents={() => Promise.all([fetchData(), refreshUpgrades()])} /> - {/* Agent total, bulk actions and status bar */} = () => { }} /> - {/* Agent list table */} ref={tableRef} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx new file mode 100644 index 00000000000000..b5d8cd8f4d72d6 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Available versions for the upgrade of the Elastic Agent +// These versions are only intended to be used as a fallback +// in the event that the updated versions cannot be retrieved from the endpoint + +export const FALLBACK_VERSIONS = [ + '8.2.0', + '8.1.3', + '8.1.2', + '8.1.1', + '8.1.0', + '8.0.1', + '8.0.0', + '7.9.3', + '7.9.2', + '7.9.1', + '7.9.0', + '7.8.1', + '7.8.0', + '7.17.3', + '7.17.2', + '7.17.1', + '7.17.0', +]; + +export const MAINTAINANCE_VALUES = [1, 2, 4, 8, 12, 24, 48]; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx index 72ca7a5b80fd7a..2122abb5e27856 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx @@ -7,34 +7,89 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiConfirmModal, + EuiComboBox, + EuiFormRow, + EuiSpacer, + EuiToolTip, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiCallOut, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; + import type { Agent } from '../../../../types'; import { sendPostAgentUpgrade, sendPostBulkAgentUpgrade, useStartServices, + useKibanaVersion, } from '../../../../hooks'; +import { FALLBACK_VERSIONS, MAINTAINANCE_VALUES } from './constants'; + interface Props { onClose: () => void; agents: Agent[] | string; agentCount: number; - version: string; } +const getVersion = (version: Array>) => version[0].value as string; + export const AgentUpgradeAgentModal: React.FunctionComponent = ({ onClose, agents, agentCount, - version, }) => { const { notifications } = useStartServices(); + const kibanaVersion = useKibanaVersion(); const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState(); + const isSingleAgent = Array.isArray(agents) && agents.length === 1; + const isSmallBatch = Array.isArray(agents) && agents.length > 1 && agents.length <= 10; const isAllAgents = agents === ''; + + const fallbackVersions = [kibanaVersion].concat(FALLBACK_VERSIONS); + const fallbackOptions: Array> = fallbackVersions.map( + (option) => ({ + label: option, + value: option, + }) + ); + const maintainanceWindows = isSmallBatch ? [0].concat(MAINTAINANCE_VALUES) : MAINTAINANCE_VALUES; + const maintainanceOptions: Array> = maintainanceWindows.map( + (option) => ({ + label: + option === 0 + ? i18n.translate('xpack.fleet.upgradeAgents.noMaintainanceWindowOption', { + defaultMessage: 'Immediately', + }) + : i18n.translate('xpack.fleet.upgradeAgents.hourLabel', { + defaultMessage: '{option} {count, plural, one {hour} other {hours}}', + values: { option, count: option === 1 }, + }), + value: option === 0 ? 0 : option * 3600, + }) + ); + const [selectedVersion, setSelectedVersion] = useState([fallbackOptions[0]]); + const [selectedMantainanceWindow, setSelectedMantainanceWindow] = useState([ + maintainanceOptions[0], + ]); + async function onSubmit() { + const version = getVersion(selectedVersion); + const rolloutOptions = + selectedMantainanceWindow.length > 0 && (selectedMantainanceWindow[0]?.value as number) > 0 + ? { + rollout_duration_seconds: selectedMantainanceWindow[0].value, + } + : {}; + try { setIsSubmitting(true); const { data, error } = isSingleAgent @@ -42,10 +97,14 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ version, }) : await sendPostBulkAgentUpgrade({ - agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, version, + agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, + ...rolloutOptions, }); if (error) { + if (error?.statusCode === 400) { + setErrors(error?.message); + } throw error; } @@ -114,39 +173,20 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ - - {isSingleAgent ? ( - - ) : ( - - )} - - - - } - tooltipContent={ - - } + <> + {isSingleAgent ? ( + + ) : ( + - - + )} + } onCancel={onClose} onConfirm={onSubmit} @@ -179,17 +219,88 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ defaultMessage="This action will upgrade the agent running on '{hostName}' to version {version}. This action can not be undone. Are you sure you wish to continue?" values={{ hostName: ((agents[0] as Agent).local_metadata.host as any).hostname, - version, + version: getVersion(selectedVersion), }} /> ) : ( )}

+ + + >) => { + setSelectedVersion(selected); + }} + /> + + + {!isSingleAgent ? ( + + + {i18n.translate('xpack.fleet.upgradeAgents.maintainanceAvailableLabel', { + defaultMessage: 'Maintainance window available', + })} + + + + + + + + + } + fullWidth + > + >) => { + setSelectedMantainanceWindow(selected); + }} + /> + + ) : null} + {errors ? ( + <> + + + ) : null}
); }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx index e4341af45cf418..9d46c636150d38 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx @@ -509,7 +509,6 @@ const mockApiCalls = ( ], owner: { github: 'elastic/integrations-services' }, latestVersion: '0.3.7', - removable: true, status: 'installed', }, } as GetInfoResponse; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 05ff443a7b0e6c..d84fab93dc8c27 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -97,7 +97,7 @@ interface Props { } export const SettingsPage: React.FC = memo(({ packageInfo, theme$ }: Props) => { - const { name, title, removable, latestVersion, version, keepPoliciesUpToDate } = packageInfo; + const { name, title, latestVersion, version, keepPoliciesUpToDate } = packageInfo; const [dryRunData, setDryRunData] = useState(); const [isUpgradingPackagePolicies, setIsUpgradingPackagePolicies] = useState(false); const getPackageInstallStatus = useGetPackageInstallStatus(); @@ -342,41 +342,39 @@ export const SettingsPage: React.FC = memo(({ packageInfo, theme$ }: Prop ) : ( - removable && ( - <> -
- -

- -

-
- -

+ <> +

+ +

+

+
+ +

+ +

+
+ + +

+

-
- - -

- -

-
-
- - ) +
+ + )} - {packageHasUsages && removable === true && ( + {packageHasUsages && (

= memo(({ packageInfo, theme$ }: Prop

)} - {removable === false && ( -

- - , - }} - /> - -

- )} )} {hideInstallOptions && isViewingOldPackage && !isUpdating && ( diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts index 9bfba13052c358..94390d2f529d22 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts @@ -29,6 +29,7 @@ import type { PostBulkAgentUpgradeResponse, PostNewAgentActionRequest, PostNewAgentActionResponse, + GetCurrentUpgradesResponse, } from '../../types'; import { useRequest, sendRequest } from './use_request'; @@ -177,3 +178,17 @@ export function sendPostBulkAgentUpgrade( ...options, }); } + +export function sendGetCurrentUpgrades() { + return sendRequest({ + path: agentRouteService.getCurrentUpgradesPath(), + method: 'get', + }); +} + +export function sendPostCancelAction(actionId: string) { + return sendRequest({ + path: agentRouteService.getCancelActionPath(actionId), + method: 'post', + }); +} diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index fc29f046aac042..2cd27e81be9d85 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -25,6 +25,7 @@ export type { Output, DataStream, Settings, + CurrentUpgrade, GetFleetStatusResponse, GetAgentPoliciesRequest, GetAgentPoliciesResponse, @@ -77,6 +78,7 @@ export type { PostEnrollmentAPIKeyResponse, PostLogstashApiKeyResponse, GetOutputsResponse, + GetCurrentUpgradesResponse, PutOutputRequest, PutOutputResponse, PostOutputRequest, diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 2a8f14f795f7c4..edcf2ed751f3eb 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -38,6 +38,7 @@ import { migratePackagePolicyToV7150 } from './migrations/to_v7_15_0'; import { migrateInstallationToV7160, migratePackagePolicyToV7160 } from './migrations/to_v7_16_0'; import { migrateInstallationToV800, migrateOutputToV800 } from './migrations/to_v8_0_0'; import { migratePackagePolicyToV820 } from './migrations/to_v8_2_0'; +import { migrateInstallationToV830 } from './migrations/to_v8_3_0'; /* * Saved object types and mappings @@ -223,7 +224,6 @@ const getSavedObjectTypes = ( name: { type: 'keyword' }, version: { type: 'keyword' }, internal: { type: 'boolean' }, - removable: { type: 'boolean' }, keep_policies_up_to_date: { type: 'boolean', index: false }, es_index_patterns: { enabled: false, @@ -262,6 +262,7 @@ const getSavedObjectTypes = ( '7.14.1': migrateInstallationToV7140, '7.16.0': migrateInstallationToV7160, '8.0.0': migrateInstallationToV800, + '8.3.0': migrateInstallationToV830, }, }, [ASSETS_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts new file mode 100644 index 00000000000000..843427f3cf8624 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectMigrationFn } from '@kbn/core/server'; + +import type { Installation } from '../../../common'; + +export const migrateInstallationToV830: SavedObjectMigrationFn = ( + installationDoc, + migrationContext +) => { + delete installationDoc.attributes.removable; + + return installationDoc; +}; diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts index 2838f2204ad967..97d7c73035e6d2 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -8,6 +8,11 @@ import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { cancelAgentAction } from './actions'; +import { bulkUpdateAgents } from './crud'; + +jest.mock('./crud'); + +const mockedBulkUpdateAgents = bulkUpdateAgents as jest.Mock; describe('Agent actions', () => { describe('cancelAgentAction', () => { @@ -67,5 +72,30 @@ describe('Agent actions', () => { }) ); }); + + it('should cancel UPGRADE action', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + esClient.search.mockResolvedValue({ + hits: { + hits: [ + { + _source: { + type: 'UPGRADE', + action_id: 'action1', + agents: ['agent1', 'agent2'], + expiration: '2022-05-12T18:16:18.019Z', + }, + }, + ], + }, + } as any); + await cancelAgentAction(esClient, 'action1'); + + expect(mockedBulkUpdateAgents).toBeCalled(); + expect(mockedBulkUpdateAgents).toBeCalledWith(expect.anything(), [ + expect.objectContaining({ agentId: 'agent1' }), + expect.objectContaining({ agentId: 'agent2' }), + ]); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index afa65bfe91fb36..c4f3530892543a 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -17,6 +17,8 @@ import type { import { AGENT_ACTIONS_INDEX, SO_SEARCH_LIMIT } from '../../../common/constants'; import { AgentActionNotFoundError } from '../../errors'; +import { bulkUpdateAgents } from './crud'; + const ONE_MONTH_IN_MS = 2592000000; export async function createAgentAction( @@ -131,6 +133,18 @@ export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: created_at: now, expiration: hit._source.expiration, }); + if (hit._source.type === 'UPGRADE') { + await bulkUpdateAgents( + esClient, + hit._source.agents.map((agentId) => ({ + agentId, + data: { + upgraded_at: null, + upgrade_started_at: null, + }, + })) + ); + } } return { diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 55c105495fd548..d7f2735e2d284e 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -267,6 +267,7 @@ async function _getCancelledActionId( ) { const res = await esClient.search({ index: AGENT_ACTIONS_INDEX, + ignore_unavailable: true, query: { bool: { must: [ @@ -296,6 +297,7 @@ async function _getCancelledActionId( async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date().toISOString()) { const res = await esClient.search({ index: AGENT_ACTIONS_INDEX, + ignore_unavailable: true, query: { bool: { must: [ @@ -331,6 +333,7 @@ async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date( nbAgents: 0, complete: false, nbAgentsAck: 0, + version: hit._source.data?.version as string, }; } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 27468e77c8e9fa..acd5761919a162 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -176,7 +176,6 @@ export async function getPackageInfo({ : resolvedPkgVersion, title: packageInfo.title || nameAsTitle(packageInfo.name), assets: Registry.groupPathsByService(paths || []), - removable: true, notice: Registry.getNoticePath(paths || []), keepPoliciesUpToDate: savedObject?.attributes.keep_policies_up_to_date ?? false, }; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index c7fc01c89eb062..6bbb91ada321cc 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -598,7 +598,6 @@ export async function createInstallation(options: { ? true : undefined; - // TODO cleanup removable flag and isUnremovablePackage function const created = await savedObjectsClient.create( PACKAGES_SAVED_OBJECT_TYPE, { @@ -609,7 +608,6 @@ export async function createInstallation(options: { es_index_patterns: toSaveESIndexPatterns, name: pkgName, version: pkgVersion, - removable: true, install_version: pkgVersion, install_status: 'installing', install_started_at: new Date().toISOString(), diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 95e65acfebef65..53e001aeee8d01 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -44,11 +44,9 @@ export async function removeInstallation(options: { esClient: ElasticsearchClient; force?: boolean; }): Promise { - const { savedObjectsClient, pkgName, pkgVersion, esClient, force } = options; + const { savedObjectsClient, pkgName, pkgVersion, esClient } = options; const installation = await getInstallation({ savedObjectsClient, pkgName }); if (!installation) throw Boom.badRequest(`${pkgName} is not installed`); - if (installation.removable === false && !force) - throw Boom.badRequest(`${pkgName} is installed by default and cannot be removed`); const { total } = await packagePolicyService.list(savedObjectsClient, { kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`, diff --git a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts index 6bc56e8316da63..5c63d0ba5dca19 100644 --- a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts @@ -391,7 +391,6 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { }, ], latestVersion: '0.3.0', - removable: true, notice: undefined, status: 'not_installed', assets: { diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 72cca61832ca07..202d13f9cd5394 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -53,6 +53,7 @@ function mapHits(hit: any, url: string): GraphWorkspaceSavedObject { const source = hit.attributes; source.id = hit.id; source.url = url; + source.updatedAt = hit.updatedAt; source.icon = 'fa-share-alt'; // looks like a graph return source; } diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap index 8cbb4aa450c7c5..32d2b96675594b 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap @@ -7,26 +7,26 @@ Array [ "testy10", "testy100", "testy101", - "testy102", "testy103", "testy104", "testy11", - "testy12", + "testy13", + "testy14", ] `; exports[`policy table changes pages when a pagination link is clicked on 2`] = ` Array [ - "testy13", - "testy14", - "testy15", "testy16", "testy17", - "testy18", "testy19", "testy2", "testy20", - "testy21", + "testy22", + "testy23", + "testy25", + "testy26", + "testy28", ] `; @@ -113,15 +113,15 @@ exports[`policy table shows empty state when there are no policies 1`] = ` exports[`policy table sorts when linked index templates header is clicked 1`] = ` Array [ "testy1", - "testy3", "testy5", "testy7", - "testy9", "testy11", "testy13", - "testy15", "testy17", "testy19", + "testy23", + "testy25", + "testy29", ] `; @@ -130,28 +130,28 @@ Array [ "testy0", "testy2", "testy4", - "testy6", "testy8", "testy10", - "testy12", "testy14", "testy16", - "testy18", + "testy20", + "testy22", + "testy26", ] `; exports[`policy table sorts when linked indices header is clicked 1`] = ` Array [ "testy1", - "testy3", "testy5", "testy7", - "testy9", "testy11", "testy13", - "testy15", "testy17", "testy19", + "testy23", + "testy25", + "testy29", ] `; @@ -160,13 +160,13 @@ Array [ "testy0", "testy2", "testy4", - "testy6", "testy8", "testy10", - "testy12", "testy14", "testy16", - "testy18", + "testy20", + "testy22", + "testy26", ] `; @@ -175,13 +175,13 @@ Array [ "testy0", "testy104", "testy103", - "testy102", "testy101", "testy100", - "testy99", "testy98", "testy97", - "testy96", + "testy95", + "testy94", + "testy92", ] `; @@ -189,29 +189,29 @@ exports[`policy table sorts when modified date header is clicked 2`] = ` Array [ "testy1", "testy2", - "testy3", "testy4", "testy5", - "testy6", "testy7", "testy8", - "testy9", "testy10", + "testy11", + "testy13", + "testy14", ] `; exports[`policy table sorts when name header is clicked 1`] = ` Array [ - "testy99", "testy98", "testy97", - "testy96", "testy95", "testy94", - "testy93", "testy92", "testy91", - "testy90", + "testy89", + "testy88", + "testy86", + "testy85", ] `; @@ -220,12 +220,12 @@ Array [ "testy0", "testy1", "testy2", - "testy3", "testy4", "testy5", - "testy6", "testy7", "testy8", - "testy9", + "testy10", + "testy11", + "testy13", ] `; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index f57f351ae0831e..620cb9d6f8dde0 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -221,6 +221,29 @@ export const POLICY_WITH_KNOWN_AND_UNKNOWN_FIELDS = { name: POLICY_NAME, } as any as PolicyFromES; +export const POLICY_MANAGED_BY_ES: PolicyFromES = { + version: 1, + modifiedDate: Date.now().toString(), + policy: { + name: POLICY_NAME, + phases: { + hot: { + min_age: '0ms', + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + _meta: { + managed: true, + }, + }, + name: POLICY_NAME, +}; + export const getGeneratedPolicies = (): PolicyFromES[] => { const policy = { phases: { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts index 0cf57f4140aa43..98d6078da031c7 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts @@ -9,7 +9,7 @@ import { act } from 'react-dom/test-utils'; import { TestBed } from '@kbn/test-jest-helpers'; import { setupEnvironment } from '../../helpers'; import { initTestBed } from '../init_test_bed'; -import { getDefaultHotPhasePolicy, POLICY_NAME } from '../constants'; +import { getDefaultHotPhasePolicy, POLICY_NAME, POLICY_MANAGED_BY_ES } from '../constants'; describe(' edit warning', () => { let testBed: TestBed; @@ -54,6 +54,19 @@ describe(' edit warning', () => { expect(exists('editWarning')).toBe(true); }); + test('an edit warning callout is shown for an existing, managed policy', async () => { + httpRequestsMockHelpers.setLoadPolicies([POLICY_MANAGED_BY_ES]); + + await act(async () => { + testBed = await initTestBed(httpSetup); + }); + const { exists, component } = testBed; + component.update(); + + expect(exists('editWarning')).toBe(true); + expect(exists('editManagedPolicyCallOut')).toBe(true); + }); + test('no indices link if no indices', async () => { httpRequestsMockHelpers.setLoadPolicies([ { ...getDefaultHotPhasePolicy(POLICY_NAME), indices: [] }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx index 771cf70e3daeaa..0e8ac17ff86c26 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx @@ -52,17 +52,27 @@ const testPolicy = { }, }; +const isUsedByAnIndex = (i: number) => i % 2 === 0; +const isDesignatedManagedPolicy = (i: number) => i > 0 && i % 3 === 0; + const policies: PolicyFromES[] = [testPolicy]; for (let i = 1; i < 105; i++) { policies.push({ version: i, modifiedDate: moment().subtract(i, 'days').toISOString(), - indices: i % 2 === 0 ? [`index${i}`] : [], + indices: isUsedByAnIndex(i) ? [`index${i}`] : [], indexTemplates: i % 2 === 0 ? [`indexTemplate${i}`] : [], name: `testy${i}`, policy: { name: `testy${i}`, phases: {}, + ...(isDesignatedManagedPolicy(i) + ? { + _meta: { + managed: true, + }, + } + : {}), }, }); } @@ -89,6 +99,20 @@ const getPolicyNames = (rendered: ReactWrapper): string[] => { return (getPolicyLinks(rendered) as ReactWrapper).map((button) => button.text()); }; +const getPolicies = (rendered: ReactWrapper) => { + const visiblePolicyNames = getPolicyNames(rendered); + const visiblePolicies = visiblePolicyNames.map((name) => { + const version = parseInt(name.replace('testy', ''), 10); + return { + version, + name, + isManagedPolicy: isDesignatedManagedPolicy(version), + isUsedByAnIndex: isUsedByAnIndex(version), + }; + }); + return visiblePolicies; +}; + const testSort = (headerName: string) => { const rendered = mountWithIntl(component); const nameHeader = findTestSubject(rendered, `tableHeaderCell_${headerName}`).find('button'); @@ -114,6 +138,7 @@ const TestComponent = ({ testPolicies }: { testPolicies: PolicyFromES[] }) => { describe('policy table', () => { beforeEach(() => { component = ; + window.localStorage.removeItem('ILM_SHOW_MANAGED_POLICIES_BY_DEFAULT'); }); test('shows empty state when there are no policies', () => { @@ -129,8 +154,23 @@ describe('policy table', () => { rendered.update(); snapshot(getPolicyNames(rendered)); }); + + test('does not show any hidden policies by default', () => { + const rendered = mountWithIntl(component); + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + expect(includeHiddenPoliciesSwitch.prop('aria-checked')).toEqual(false); + const visiblePolicies = getPolicies(rendered); + const hasManagedPolicies = visiblePolicies.some((p) => { + const policyRow = findTestSubject(rendered, `policyTableRow-${p.name}`); + const warningBadge = findTestSubject(policyRow, 'managedPolicyBadge'); + return warningBadge.exists(); + }); + expect(hasManagedPolicies).toEqual(false); + }); + test('shows more policies when "Rows per page" value is increased', () => { const rendered = mountWithIntl(component); + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); perPageButton.simulate('click'); rendered.update(); @@ -139,6 +179,36 @@ describe('policy table', () => { rendered.update(); expect(getPolicyNames(rendered).length).toBe(25); }); + + test('shows hidden policies with Managed badges when setting is switched on', () => { + const rendered = mountWithIntl(component); + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + includeHiddenPoliciesSwitch.find('button').simulate('click'); + rendered.update(); + + // Increase page size for better sample set that contains managed indices + // Since table is ordered alphabetically and not numerically + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); + perPageButton.simulate('click'); + rendered.update(); + const numberOfRowsButton = rendered.find('.euiContextMenuItem').at(2); + numberOfRowsButton.simulate('click'); + rendered.update(); + + const visiblePolicies = getPolicies(rendered); + expect(visiblePolicies.filter((p) => p.isManagedPolicy).length).toBeGreaterThan(0); + + visiblePolicies.forEach((p) => { + const policyRow = findTestSubject(rendered, `policyTableRow-${p.name}`); + const warningBadge = findTestSubject(policyRow, 'managedPolicyBadge'); + if (p.isManagedPolicy) { + expect(warningBadge.exists()).toBeTruthy(); + } else { + expect(warningBadge.exists()).toBeFalsy(); + } + }); + }); + test('filters based on content of search input', () => { const rendered = mountWithIntl(component); const searchInput = rendered.find('.euiFieldSearch').first(); @@ -167,7 +237,11 @@ describe('policy table', () => { }); test('delete policy button is enabled when there are no linked indices', () => { const rendered = mountWithIntl(component); - const policyRow = findTestSubject(rendered, `policyTableRow-testy1`); + const visiblePolicies = getPolicies(rendered); + const unusedPolicy = visiblePolicies.find((p) => !p.isUsedByAnIndex); + expect(unusedPolicy).toBeDefined(); + + const policyRow = findTestSubject(rendered, `policyTableRow-${unusedPolicy!.name}`); const deleteButton = findTestSubject(policyRow, 'deletePolicy'); expect(deleteButton.props().disabled).toBeFalsy(); }); @@ -179,6 +253,36 @@ describe('policy table', () => { rendered.update(); expect(findTestSubject(rendered, 'deletePolicyModal').exists()).toBeTruthy(); }); + + test('confirmation modal shows warning when delete button is pressed for a hidden policy', () => { + const rendered = mountWithIntl(component); + + // Toggles switch to show managed policies + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + includeHiddenPoliciesSwitch.find('button').simulate('click'); + rendered.update(); + + // Increase page size for better sample set that contains managed indices + // Since table is ordered alphabetically and not numerically + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); + perPageButton.simulate('click'); + rendered.update(); + const numberOfRowsButton = rendered.find('.euiContextMenuItem').at(2); + numberOfRowsButton.simulate('click'); + rendered.update(); + + const visiblePolicies = getPolicies(rendered); + const managedPolicy = visiblePolicies.find((p) => p.isManagedPolicy && !p.isUsedByAnIndex); + expect(managedPolicy).toBeDefined(); + + const policyRow = findTestSubject(rendered, `policyTableRow-${managedPolicy!.name}`); + const addPolicyToTemplateButton = findTestSubject(policyRow, 'deletePolicy'); + addPolicyToTemplateButton.simulate('click'); + rendered.update(); + expect(findTestSubject(rendered, 'deletePolicyModal').exists()).toBeTruthy(); + expect(findTestSubject(rendered, 'deleteManagedPolicyCallOut').exists()).toBeTruthy(); + }); + test('add index template modal shows when add policy to index template button is pressed', () => { const rendered = mountWithIntl(component); const policyRow = findTestSubject(rendered, `policyTableRow-${testPolicy.name}`); @@ -190,8 +294,8 @@ describe('policy table', () => { test('displays policy properties', () => { const rendered = mountWithIntl(component); const firstRow = findTestSubject(rendered, 'policyTableRow-testy0'); - const policyName = findTestSubject(firstRow, 'policy-name').text(); - expect(policyName).toBe(`Name${testPolicy.name}`); + const policyName = findTestSubject(firstRow, 'policyTablePolicyNameLink').text(); + expect(policyName).toBe(`${testPolicy.name}`); const policyIndexTemplates = findTestSubject(firstRow, 'policy-indexTemplates').text(); expect(policyIndexTemplates).toBe(`Linked index templates${testPolicy.indexTemplates.length}`); const policyIndices = findTestSubject(firstRow, 'policy-indices').text(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts new file mode 100644 index 00000000000000..0eb5ae22fd01c1 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +function parseJsonOrDefault(value: string | null, defaultValue: Obj): Obj { + if (!value) { + return defaultValue; + } + try { + return JSON.parse(value) as Obj; + } catch (e) { + return defaultValue; + } +} + +export function useStateWithLocalStorage( + key: string, + defaultState: State +): [State, Dispatch>] { + const storageState = localStorage.getItem(key); + const [state, setState] = useState(parseJsonOrDefault(storageState, defaultState)); + useEffect(() => { + localStorage.setItem(key, JSON.stringify(state)); + }, [key, state]); + return [state, setState]; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx index 8b0c21e9999c04..c2acc89fe34d18 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx @@ -6,7 +6,7 @@ */ import React, { FunctionComponent, useState } from 'react'; -import { EuiLink, EuiText } from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { useEditPolicyContext } from '../edit_policy_context'; import { getIndicesListPath } from '../../../services/navigation'; @@ -14,7 +14,7 @@ import { useKibana } from '../../../../shared_imports'; import { IndexTemplatesFlyout } from '../../../components/index_templates_flyout'; export const EditWarning: FunctionComponent = () => { - const { isNewPolicy, indices, indexTemplates, policyName } = useEditPolicyContext(); + const { isNewPolicy, indices, indexTemplates, policyName, policy } = useEditPolicyContext(); const { services: { getUrlForApp }, } = useKibana(); @@ -67,6 +67,8 @@ export const EditWarning: FunctionComponent = () => { ) : ( indexTemplatesLink ); + const isManagedPolicy = policy?._meta?.managed; + return ( <> {isIndexTemplatesFlyoutShown && ( @@ -77,6 +79,29 @@ export const EditWarning: FunctionComponent = () => { /> )} + {isManagedPolicy && ( + <> + + } + color="danger" + iconType="alert" + data-test-subj="editManagedPolicyCallOut" + > +

+ +

+
+ + + )}

void; } export class ConfirmDelete extends Component { + public state = { + isDeleteConfirmed: false, + }; + + setIsDeleteConfirmed = (confirmed: boolean) => { + this.setState({ + isDeleteConfirmed: confirmed, + }); + }; + deletePolicy = async () => { const { policyToDelete, callback } = this.props; const policyName = policyToDelete.name; @@ -43,8 +53,12 @@ export class ConfirmDelete extends Component { callback(); } }; + isPolicyPolicy = true; render() { const { policyToDelete, onCancel } = this.props; + const { isDeleteConfirmed } = this.state; + const isManagedPolicy = policyToDelete.policy?._meta?.managed; + const title = i18n.translate('xpack.indexLifecycleMgmt.confirmDelete.title', { defaultMessage: 'Delete policy "{name}"', values: { name: policyToDelete.name }, @@ -68,13 +82,47 @@ export class ConfirmDelete extends Component { /> } buttonColor="danger" + confirmButtonDisabled={isManagedPolicy ? !isDeleteConfirmed : false} > -

- -
+ {isManagedPolicy ? ( + + } + color="danger" + iconType="alert" + data-test-subj="deleteManagedPolicyCallOut" + > +

+ +

+ + } + checked={isDeleteConfirmed} + onChange={(e) => this.setIsDeleteConfirmed(e.target.checked)} + /> +
+ ) : ( +
+ +
+ )} ); } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx index 8a89759a4225eb..2d79737baf2bc6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx @@ -5,8 +5,17 @@ * 2.0. */ -import React from 'react'; -import { EuiButtonEmpty, EuiLink, EuiInMemoryTable, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { + EuiButtonEmpty, + EuiLink, + EuiInMemoryTable, + EuiToolTip, + EuiButtonIcon, + EuiBadge, + EuiFlexItem, + EuiSwitch, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -15,6 +24,8 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { useHistory } from 'react-router-dom'; import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useStateWithLocalStorage } from '../../../lib/settings_local_storage'; import { PolicyFromES } from '../../../../../common/types'; import { useKibana } from '../../../../shared_imports'; import { getIndicesListPath, getPolicyEditPath } from '../../../services/navigation'; @@ -45,17 +56,63 @@ const actionTooltips = { ), }; +const managedPolicyTooltips = { + badge: i18n.translate('xpack.indexLifecycleMgmt.policyTable.templateBadgeType.managedLabel', { + defaultMessage: 'Managed', + }), + badgeTooltip: i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.templateBadgeType.managedDescription', + { + defaultMessage: + 'This policy is preconfigured and managed by Elastic; editing or deleting this policy might break Kibana.', + } + ), +}; + interface Props { policies: PolicyFromES[]; } +const SHOW_MANAGED_POLICIES_BY_DEFAULT = 'ILM_SHOW_MANAGED_POLICIES_BY_DEFAULT'; + export const PolicyTable: React.FunctionComponent = ({ policies }) => { const history = useHistory(); const { services: { getUrlForApp }, } = useKibana(); - + const [managedPoliciesVisible, setManagedPoliciesVisible] = useStateWithLocalStorage( + SHOW_MANAGED_POLICIES_BY_DEFAULT, + false + ); const { setListAction } = usePolicyListContext(); + const searchOptions = useMemo( + () => ({ + box: { incremental: true, 'data-test-subj': 'ilmSearchBar' }, + toolsRight: ( + + setManagedPoliciesVisible(event.target.checked)} + label={ + + } + /> + + ), + }), + [managedPoliciesVisible, setManagedPoliciesVisible] + ); + + const filteredPolicies = useMemo(() => { + return managedPoliciesVisible + ? policies + : policies.filter((item) => !item.policy?._meta?.managed); + }, [policies, managedPoliciesVisible]); const columns: Array> = [ { @@ -65,17 +122,31 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { defaultMessage: 'Name', }), sortable: true, - render: (value: string) => { + render: (value: string, item) => { + const isManaged = item.policy?._meta?.managed; return ( - - trackUiMetric(METRIC_TYPE.CLICK, UIM_EDIT_CLICK) + <> + + trackUiMetric(METRIC_TYPE.CLICK, UIM_EDIT_CLICK) + )} + > + {value} + + + {isManaged && ( + <> +   + + + {managedPolicyTooltips.badge} + + + )} - > - {value} - + ); }, }, @@ -191,11 +262,9 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { direction: 'asc', }, }} - search={{ - box: { incremental: true, 'data-test-subj': 'ilmSearchBar' }, - }} + search={searchOptions} tableLayout="auto" - items={policies} + items={filteredPolicies} columns={columns} rowProps={(policy: PolicyFromES) => ({ 'data-test-subj': `policyTableRow-${policy.name}` })} /> diff --git a/x-pack/plugins/infra/server/mocks.ts b/x-pack/plugins/infra/server/mocks.ts new file mode 100644 index 00000000000000..5b587a1fe80d50 --- /dev/null +++ b/x-pack/plugins/infra/server/mocks.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createLogViewsServiceSetupMock, + createLogViewsServiceStartMock, +} from './services/log_views/log_views_service.mock'; +import { InfraPluginSetup, InfraPluginStart } from './types'; + +const createInfraSetupMock = () => { + const infraSetupMock: jest.Mocked = { + defineInternalSourceConfiguration: jest.fn(), + logViews: createLogViewsServiceSetupMock(), + }; + + return infraSetupMock; +}; + +const createInfraStartMock = () => { + const infraStartMock: jest.Mocked = { + getMetricIndices: jest.fn(), + logViews: createLogViewsServiceStartMock(), + }; + return infraStartMock; +}; + +export const infraPluginMock = { + createSetupContract: createInfraSetupMock, + createStartContract: createInfraStartMock, +}; diff --git a/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts b/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts index becd5a015b2ecc..e472e30fae2b4f 100644 --- a/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts +++ b/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts @@ -6,7 +6,11 @@ */ import { createLogViewsClientMock } from './log_views_client.mock'; -import { LogViewsServiceStart } from './types'; +import { LogViewsServiceSetup, LogViewsServiceStart } from './types'; + +export const createLogViewsServiceSetupMock = (): jest.Mocked => ({ + defineInternalLogView: jest.fn(), +}); export const createLogViewsServiceStartMock = (): jest.Mocked => ({ getClient: jest.fn((_savedObjectsClient: any, _elasticsearchClient: any) => diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index d42af9aa3932c2..12c5dafb5d9429 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -203,10 +203,11 @@ export const getDatatableVisualization = ({ ) .map((accessor) => ({ columnId: accessor, - triggerIcon: - columnMap[accessor].hidden || columnMap[accessor].collapseFn - ? 'invisible' - : undefined, + triggerIcon: columnMap[accessor].hidden + ? 'invisible' + : columnMap[accessor].collapseFn + ? 'aggregate' + : undefined, })), supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx index b8a5819d455326..b12f50a7b35a0c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx @@ -59,6 +59,17 @@ export function ColorIndicator({ })} /> )} + {accessorConfig.triggerIcon === 'aggregate' && ( + + )} {accessorConfig.triggerIcon === 'colorBy' && ( ( + + {i18n.translate('xpack.lens.collapse.label', { defaultMessage: 'Collapse by' })} + {''} + + + + } display="columnCompressed" fullWidth > diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 1f2ee1266ddb74..1ffc300542b09b 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -557,7 +557,7 @@ export type VisualizationDimensionEditorProps = VisualizationConfig export interface AccessorConfig { columnId: string; - triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible'; + triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible' | 'aggregate'; color?: string; palette?: string[] | Array<{ color: string; stop: number }>; } diff --git a/x-pack/plugins/lens/public/vis_type_alias.ts b/x-pack/plugins/lens/public/vis_type_alias.ts index be8a5620ce614f..11a97ae82470f2 100644 --- a/x-pack/plugins/lens/public/vis_type_alias.ts +++ b/x-pack/plugins/lens/public/vis_type_alias.ts @@ -31,12 +31,13 @@ export const getLensAliasConfig = (): VisTypeAlias => ({ docTypes: ['lens'], searchFields: ['title^3'], toListItem(savedObject) { - const { id, type, attributes } = savedObject; + const { id, type, updatedAt, attributes } = savedObject; const { title, description } = attributes as { title: string; description?: string }; return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: 'lens', icon: 'lensApp', diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index cb6e6cff2d70e6..ff5a692a76e960 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -356,7 +356,7 @@ const referenceLineLayerToExpression = ( chain: [ { type: 'function', - function: 'extendedReferenceLineLayer', + function: 'referenceLineLayer', arguments: { layerId: [layer.layerId], yConfig: layer.yConfig diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 096c395b31eaf4..b35247f4d9d974 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -276,7 +276,7 @@ export const getXyVisualization = ({ ? [ { columnId: dataLayer.splitAccessor, - triggerIcon: dataLayer.collapseFn ? ('invisible' as const) : ('colorBy' as const), + triggerIcon: dataLayer.collapseFn ? ('aggregate' as const) : ('colorBy' as const), palette: dataLayer.collapseFn ? undefined : paletteService diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index d43d4c4cb2a387..53765ed69cdac3 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -181,7 +181,7 @@ describe('Lens migrations', () => { }); describe('7.8.0 auto timestamp', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', @@ -533,7 +533,7 @@ describe('Lens migrations', () => { }); describe('7.11.0 remove suggested priority', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', @@ -618,7 +618,7 @@ describe('Lens migrations', () => { }); describe('7.12.0 restructure datatable state', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mock-saved-object-id', @@ -691,7 +691,7 @@ describe('Lens migrations', () => { }); describe('7.13.0 rename operations for Formula', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -869,7 +869,7 @@ describe('Lens migrations', () => { }); describe('7.14.0 remove time zone from date histogram', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -961,7 +961,7 @@ describe('Lens migrations', () => { }); describe('7.15.0 add layer type information', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1143,7 +1143,7 @@ describe('Lens migrations', () => { }); describe('7.16.0 move reversed default palette to custom palette', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1417,7 +1417,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 update filter reference schema', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1523,7 +1523,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 rename records field', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1709,7 +1709,7 @@ describe('Lens migrations', () => { }); describe('8.1.0 add parentFormat to terms operation', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1785,7 +1785,7 @@ describe('Lens migrations', () => { describe('8.2.0', () => { describe('last_value columns', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -1877,7 +1877,7 @@ describe('Lens migrations', () => { }); describe('rename fitRowToContent to new detailed rowHeight and rowHeightLines', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; function getExample(fitToContent: boolean) { return { type: 'lens', @@ -1996,7 +1996,7 @@ describe('Lens migrations', () => { }); describe('8.2.0 include empty rows for date histogram columns', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -2067,7 +2067,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 old metric visualization defaults', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', @@ -2117,7 +2117,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 - convert legend sizes to strings', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const migrate = migrations['8.3.0']; const autoLegendSize = 'auto'; @@ -2185,7 +2185,7 @@ describe('Lens migrations', () => { }); describe('8.3.0 valueLabels in XY', () => { - const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const context = { log: { warn: () => {} } } as unknown as SavedObjectMigrationContext; const example = { type: 'lens', id: 'mocked-saved-object-id', diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 3870bab9fad65b..e6daa2cb99439c 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -198,7 +198,7 @@ const removeLensAutoDate: SavedObjectMigrationFn; diff --git a/x-pack/plugins/maps/common/mvt_request_body.ts b/x-pack/plugins/maps/common/mvt_request_body.ts index e5517b23e0cba1..c2d367f89fa8ac 100644 --- a/x-pack/plugins/maps/common/mvt_request_body.ts +++ b/x-pack/plugins/maps/common/mvt_request_body.ts @@ -21,6 +21,7 @@ export function getAggsTileRequest({ encodedRequestBody, geometryFieldName, gridPrecision, + hasLabels, index, renderAs = RENDER_AS.POINT, x, @@ -30,6 +31,7 @@ export function getAggsTileRequest({ encodedRequestBody: string; geometryFieldName: string; gridPrecision: number; + hasLabels: boolean; index: string; renderAs: RENDER_AS; x: number; @@ -50,6 +52,7 @@ export function getAggsTileRequest({ aggs: requestBody.aggs, fields: requestBody.fields, runtime_mappings: requestBody.runtime_mappings, + with_labels: hasLabels, }, }; } @@ -57,6 +60,7 @@ export function getAggsTileRequest({ export function getHitsTileRequest({ encodedRequestBody, geometryFieldName, + hasLabels, index, x, y, @@ -64,6 +68,7 @@ export function getHitsTileRequest({ }: { encodedRequestBody: string; geometryFieldName: string; + hasLabels: boolean; index: string; x: number; y: number; @@ -86,6 +91,7 @@ export function getHitsTileRequest({ ), runtime_mappings: requestBody.runtime_mappings, track_total_hits: typeof requestBody.size === 'number' ? requestBody.size + 1 : false, + with_labels: hasLabels, }, }; } diff --git a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts index e796ecad332ca1..ec9cec3a914ba0 100644 --- a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts @@ -87,6 +87,7 @@ export class HeatmapLayer extends AbstractLayer { async syncData(syncContext: DataRequestContext) { await syncMvtSourceData({ + hasLabels: false, layerId: this.getId(), layerName: await this.getDisplayName(), prevDataRequest: this.getSourceDataRequest(), diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts index 1f710879d9dd7b..dae0f5343dcc91 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts @@ -52,6 +52,7 @@ describe('syncMvtSourceData', () => { const syncContext = new MockSyncContext({ dataFilters: {} }); await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: undefined, @@ -82,6 +83,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf', refreshToken: '12345', + hasLabels: false, }); }); @@ -99,6 +101,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -112,6 +115,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -142,6 +146,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -155,6 +160,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -182,6 +188,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -195,6 +202,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -230,6 +238,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -243,6 +252,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'barfoo', // tileSourceLayer is different then mockSource tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -270,6 +280,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -283,6 +294,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -310,6 +322,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -323,6 +336,49 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, + }; + }, + } as unknown as DataRequest, + requestMeta: { ...prevRequestMeta }, + source: mockSource, + syncContext, + }); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.startLoading); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.stopLoading); + }); + + test('Should re-sync when hasLabel state changes', async () => { + const syncContext = new MockSyncContext({ dataFilters: {} }); + const prevRequestMeta = { + ...syncContext.dataFilters, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + fieldNames: [], + sourceMeta: {}, + isForceRefresh: false, + isFeatureEditorOpenForLayer: false, + }; + + await syncMvtSourceData({ + hasLabels: true, + layerId: 'layer1', + layerName: 'my layer', + prevDataRequest: { + getMeta: () => { + return prevRequestMeta; + }, + getData: () => { + return { + tileMinZoom: 4, + tileMaxZoom: 14, + tileSourceLayer: 'aggs', + tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', + refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -340,6 +396,7 @@ describe('syncMvtSourceData', () => { const syncContext = new MockSyncContext({ dataFilters: {} }); await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: undefined, diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts index 76550090109a1c..19ad39e41a2388 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts @@ -20,9 +20,11 @@ export interface MvtSourceData { tileMaxZoom: number; tileUrl: string; refreshToken: string; + hasLabels: boolean; } export async function syncMvtSourceData({ + hasLabels, layerId, layerName, prevDataRequest, @@ -30,6 +32,7 @@ export async function syncMvtSourceData({ source, syncContext, }: { + hasLabels: boolean; layerId: string; layerName: string; prevDataRequest: DataRequest | undefined; @@ -56,7 +59,10 @@ export async function syncMvtSourceData({ }, }); const canSkip = - !syncContext.forceRefreshDueToDrawing && noChangesInSourceState && noChangesInSearchState; + !syncContext.forceRefreshDueToDrawing && + noChangesInSourceState && + noChangesInSearchState && + prevData.hasLabels === hasLabels; if (canSkip) { return; @@ -72,7 +78,7 @@ export async function syncMvtSourceData({ ? uuid() : prevData.refreshToken; - const tileUrl = await source.getTileUrl(requestMeta, refreshToken); + const tileUrl = await source.getTileUrl(requestMeta, refreshToken, hasLabels); if (source.isESSource()) { syncContext.inspectorAdapters.vectorTiles.addLayer(layerId, layerName, tileUrl); } @@ -82,6 +88,7 @@ export async function syncMvtSourceData({ tileMinZoom: source.getMinZoom(), tileMaxZoom: source.getMaxZoom(), refreshToken, + hasLabels, }; syncContext.stopLoading(SOURCE_DATA_REQUEST_ID, requestToken, sourceData, {}); } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx index 462ea5b0cc8f12..7eaec94eac0a28 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx @@ -219,6 +219,7 @@ export class MvtVectorLayer extends AbstractVectorLayer { await this._syncSupportsFeatureEditing({ syncContext, source: this.getSource() }); await syncMvtSourceData({ + hasLabels: this.getCurrentStyle().hasLabels(), layerId: this.getId(), layerName: await this.getDisplayName(), prevDataRequest: this.getSourceDataRequest(), diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 82ca62c7f33df6..73e036b1057307 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -736,7 +736,10 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { } } + const isSourceGeoJson = !this.getSource().isMvt(); const filterExpr = getPointFilterExpression( + isSourceGeoJson, + this.getSource().isESSource(), this._getJoinFilterExpression(), timesliceMaskConfig ); @@ -843,6 +846,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { const isSourceGeoJson = !this.getSource().isMvt(); const filterExpr = getLabelFilterExpression( isSourceGeoJson, + this.getSource().isESSource(), this._getJoinFilterExpression(), timesliceMaskConfig ); diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts index 92045f59111767..36e07d7383d184 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts @@ -10,6 +10,7 @@ import { AGG_TYPE, COLOR_MAP_TYPE, FIELD_ORIGIN, + LAYER_TYPE, SCALING_TYPES, SOURCE_TYPES, STYLE_TYPE, @@ -21,10 +22,11 @@ import { CountAggDescriptor, EMSFileSourceDescriptor, ESSearchSourceDescriptor, + JoinDescriptor, VectorStylePropertiesDescriptor, } from '../../../../../common/descriptor_types'; import { VectorStyle } from '../../../styles/vector/vector_style'; -import { GeoJsonVectorLayer } from '../../vector_layer'; +import { GeoJsonVectorLayer, MvtVectorLayer } from '../../vector_layer'; import { EMSFileSource } from '../../../sources/ems_file_source'; // @ts-ignore import { ESSearchSource } from '../../../sources/es_search_source'; @@ -38,14 +40,14 @@ function createChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle, + layerType, }: { sourceDescriptor: EMSFileSourceDescriptor | ESSearchSourceDescriptor; leftField: string; rightIndexPatternId: string; rightIndexPatternTitle: string; rightTermField: string; - setLabelStyle: boolean; + layerType: LAYER_TYPE.GEOJSON_VECTOR | LAYER_TYPE.MVT_VECTOR; }) { const metricsDescriptor: CountAggDescriptor = { type: AGG_TYPE.COUNT }; const joinId = uuid(); @@ -75,7 +77,8 @@ function createChoroplethLayerDescriptor({ }, }, }; - if (setLabelStyle) { + // Styling label by join metric with MVT is not supported + if (layerType === LAYER_TYPE.GEOJSON_VECTOR) { styleProperties[VECTOR_STYLES.LABEL_TEXT] = { type: STYLE_TYPE.DYNAMIC, options: { @@ -88,26 +91,34 @@ function createChoroplethLayerDescriptor({ }; } - return GeoJsonVectorLayer.createDescriptor({ - joins: [ - { - leftField, - right: { - type: SOURCE_TYPES.ES_TERM_SOURCE, - id: joinId, - indexPatternId: rightIndexPatternId, - indexPatternTitle: rightIndexPatternTitle, - term: rightTermField, - metrics: [metricsDescriptor], - applyGlobalQuery: true, - applyGlobalTime: true, - applyForceRefresh: true, - }, + const joins = [ + { + leftField, + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + id: joinId, + indexPatternId: rightIndexPatternId, + indexPatternTitle: rightIndexPatternTitle, + term: rightTermField, + metrics: [metricsDescriptor], + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, }, - ], - sourceDescriptor, - style: VectorStyle.createDescriptor(styleProperties), - }); + } as JoinDescriptor, + ]; + + return layerType === LAYER_TYPE.MVT_VECTOR + ? MvtVectorLayer.createDescriptor({ + joins, + sourceDescriptor, + style: VectorStyle.createDescriptor(styleProperties), + }) + : GeoJsonVectorLayer.createDescriptor({ + joins, + sourceDescriptor, + style: VectorStyle.createDescriptor(styleProperties), + }); } export function createEmsChoroplethLayerDescriptor({ @@ -132,7 +143,7 @@ export function createEmsChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle: true, + layerType: LAYER_TYPE.GEOJSON_VECTOR, }); } @@ -165,6 +176,6 @@ export function createEsChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle: false, // Styling label by join metric with MVT is not supported + layerType: LAYER_TYPE.MVT_VECTOR, }); } diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts index 5792d861f6f5c6..f295464126c961 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts @@ -24,9 +24,7 @@ import { } from '../../../../../../common/constants'; import { GeoJsonVectorLayer } from '../../../vector_layer'; import { VectorStyle } from '../../../../styles/vector/vector_style'; -// @ts-ignore import { ESSearchSource } from '../../../../sources/es_search_source'; -// @ts-ignore import { ESPewPewSource } from '../../../../sources/es_pew_pew_source'; import { getDefaultDynamicProperties } from '../../../../styles/vector/vector_style_defaults'; import { APM_INDEX_PATTERN_TITLE } from '../observability'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index b08b95a58a4957..831dc90871dff3 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -306,10 +306,10 @@ describe('ESGeoGridSource', () => { }); it('getTileUrl', async () => { - const tileUrl = await mvtGeogridSource.getTileUrl(vectorSourceRequestMeta, '1234'); + const tileUrl = await mvtGeogridSource.getTileUrl(vectorSourceRequestMeta, '1234', false); expect(tileUrl).toEqual( - "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&renderAs=heatmap&token=1234" + "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&hasLabels=false&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&renderAs=heatmap&token=1234" ); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 66a07804c0105c..1680b1d2fb55c1 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -471,7 +471,11 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo return 'aggs'; } - async getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise { + async getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); searchSource.setField('aggs', this.getValueAggsDsl(indexPattern)); @@ -484,6 +488,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo ?geometryFieldName=${this._descriptor.geoField}\ &index=${indexPattern.title}\ &gridPrecision=${this._getGeoGridPrecisionResolutionDelta()}\ +&hasLabels=${hasLabels}\ &requestBody=${encodeMvtResponseBody(searchSource.getSearchRequestBody())}\ &renderAs=${this._descriptor.requestType}\ &token=${refreshToken}`; diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx similarity index 67% rename from x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js rename to x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx index a38c7692053042..910181d6a28687 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx @@ -8,17 +8,35 @@ import React from 'react'; import turfBbox from '@turf/bbox'; import { multiPoint } from '@turf/helpers'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; +import { type Filter, buildExistsFilter } from '@kbn/es-query'; +import { lastValueFrom } from 'rxjs'; +import type { + AggregationsGeoBoundsAggregate, + LatLonGeoLocation, + TopLeftBottomRightGeoBounds, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { UpdateSourceEditor } from './update_source_editor'; import { i18n } from '@kbn/i18n'; +// @ts-expect-error +import { UpdateSourceEditor } from './update_source_editor'; import { SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { getDataSourceLabel, getDataViewLabel } from '../../../../common/i18n_getters'; +// @ts-expect-error import { convertToLines } from './convert_to_lines'; import { AbstractESAggSource } from '../es_agg_source'; import { registerSource } from '../source_registry'; import { turfBboxToBounds } from '../../../../common/elasticsearch_util'; import { DataRequestAbortError } from '../../util/data_request'; import { makePublicExecutionContext } from '../../../util'; +import { SourceEditorArgs } from '../source'; +import { + ESPewPewSourceDescriptor, + MapExtent, + VectorSourceRequestMeta, +} from '../../../../common/descriptor_types'; +import { isValidStringConfig } from '../../util/valid_string_config'; +import { BoundsRequestMeta, GeoJsonWithMeta } from '../vector_source'; const MAX_GEOTILE_LEVEL = 29; @@ -27,20 +45,30 @@ export const sourceTitle = i18n.translate('xpack.maps.source.pewPewTitle', { }); export class ESPewPewSource extends AbstractESAggSource { - static type = SOURCE_TYPES.ES_PEW_PEW; + readonly _descriptor: ESPewPewSourceDescriptor; - static createDescriptor(descriptor) { + static createDescriptor(descriptor: Partial): ESPewPewSourceDescriptor { const normalizedDescriptor = AbstractESAggSource.createDescriptor(descriptor); + if (!isValidStringConfig(descriptor.sourceGeoField)) { + throw new Error('Cannot create ESPewPewSourceDescriptor, sourceGeoField is not provided'); + } + if (!isValidStringConfig(descriptor.destGeoField)) { + throw new Error('Cannot create ESPewPewSourceDescriptor, destGeoField is not provided'); + } return { ...normalizedDescriptor, - type: ESPewPewSource.type, - indexPatternId: descriptor.indexPatternId, - sourceGeoField: descriptor.sourceGeoField, - destGeoField: descriptor.destGeoField, + type: SOURCE_TYPES.ES_PEW_PEW, + sourceGeoField: descriptor.sourceGeoField!, + destGeoField: descriptor.destGeoField!, }; } - renderSourceSettingsEditor({ onChange }) { + constructor(descriptor: ESPewPewSourceDescriptor) { + super(descriptor); + this._descriptor = descriptor; + } + + renderSourceSettingsEditor({ onChange }: SourceEditorArgs) { return ( void) => void, + isRequestStillActive: () => boolean, + inspectorAdapters: Adapters + ): Promise { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); searchSource.setField('trackTotalHits', false); @@ -151,14 +179,10 @@ export class ESPewPewSource extends AbstractESAggSource { // Some underlying indices may not contain geo fields // Filter out documents without geo fields to avoid shard failures for those indices searchSource.setField('filter', [ - ...searchSource.getField('filter'), + ...(searchSource.getField('filter') as Filter[]), // destGeoField exists ensured by buffer filter // so only need additional check for sourceGeoField - { - exists: { - field: this._descriptor.sourceGeoField, - }, - }, + buildExistsFilter({ name: this._descriptor.sourceGeoField, type: 'geo_point' }, indexPattern), ]); const esResponse = await this._runEsQuery({ @@ -188,7 +212,10 @@ export class ESPewPewSource extends AbstractESAggSource { return this._descriptor.destGeoField; } - async getBoundsForFilters(boundsFilters, registerCancelCallback) { + async getBoundsForFilters( + boundsFilters: BoundsRequestMeta, + registerCancelCallback: (callback: () => void) => void + ): Promise { const searchSource = await this.makeSearchSource(boundsFilters, 0); searchSource.setField('trackTotalHits', false); searchSource.setField('aggs', { @@ -208,31 +235,36 @@ export class ESPewPewSource extends AbstractESAggSource { try { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - const { rawResponse: esResp } = await searchSource - .fetch$({ + const { rawResponse: esResp } = await lastValueFrom( + searchSource.fetch$({ abortSignal: abortController.signal, legacyHitsTotal: false, executionContext: makePublicExecutionContext('es_pew_pew_source:bounds'), }) - .toPromise(); - if (esResp.aggregations.destFitToBounds.bounds) { + ); + const destBounds = (esResp.aggregations?.destFitToBounds as AggregationsGeoBoundsAggregate) + .bounds as TopLeftBottomRightGeoBounds; + if (destBounds) { corners.push([ - esResp.aggregations.destFitToBounds.bounds.top_left.lon, - esResp.aggregations.destFitToBounds.bounds.top_left.lat, + (destBounds.top_left as LatLonGeoLocation).lon, + (destBounds.top_left as LatLonGeoLocation).lat, ]); corners.push([ - esResp.aggregations.destFitToBounds.bounds.bottom_right.lon, - esResp.aggregations.destFitToBounds.bounds.bottom_right.lat, + (destBounds.bottom_right as LatLonGeoLocation).lon, + (destBounds.bottom_right as LatLonGeoLocation).lat, ]); } - if (esResp.aggregations.sourceFitToBounds.bounds) { + const sourceBounds = ( + esResp.aggregations?.sourceFitToBounds as AggregationsGeoBoundsAggregate + ).bounds as TopLeftBottomRightGeoBounds; + if (sourceBounds) { corners.push([ - esResp.aggregations.sourceFitToBounds.bounds.top_left.lon, - esResp.aggregations.sourceFitToBounds.bounds.top_left.lat, + (sourceBounds.top_left as LatLonGeoLocation).lon, + (sourceBounds.top_left as LatLonGeoLocation).lat, ]); corners.push([ - esResp.aggregations.sourceFitToBounds.bounds.bottom_right.lon, - esResp.aggregations.sourceFitToBounds.bounds.bottom_right.lat, + (sourceBounds.bottom_right as LatLonGeoLocation).lon, + (sourceBounds.bottom_right as LatLonGeoLocation).lat, ]); } } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.ts similarity index 100% rename from x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.js rename to x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.ts diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index 37ecbfdebab110..aa128e3c7d8ff2 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_defaults'; import { GeoJsonVectorLayer } from '../../layers/vector_layer'; -// @ts-ignore import { ESPewPewSource, sourceTitle } from './es_pew_pew_source'; import { VectorStyle } from '../../styles/vector/vector_style'; import { @@ -24,7 +23,11 @@ import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../layers'; -import { ColorDynamicOptions, SizeDynamicOptions } from '../../../../common/descriptor_types'; +import { + ColorDynamicOptions, + ESPewPewSourceDescriptor, + SizeDynamicOptions, +} from '../../../../common/descriptor_types'; import { Point2PointLayerIcon } from '../../layers/wizards/icons/point_2_point_layer_icon'; export const point2PointLayerWizardConfig: LayerWizard = { @@ -36,7 +39,7 @@ export const point2PointLayerWizardConfig: LayerWizard = { }), icon: Point2PointLayerIcon, renderWizard: ({ previewLayers }: RenderWizardArguments) => { - const onSourceConfigChange = (sourceConfig: unknown) => { + const onSourceConfigChange = (sourceConfig: Partial) => { if (!sourceConfig) { previewLayers([]); return; diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts index 2df2e119df30cc..24470ae0fade79 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts @@ -114,9 +114,9 @@ describe('ESSearchSource', () => { geoField: geoFieldName, indexPatternId: 'ipId', }); - const tileUrl = await esSearchSource.getTileUrl(searchFilters, '1234'); + const tileUrl = await esSearchSource.getTileUrl(searchFilters, '1234', false); expect(tileUrl).toBe( - `rootdir/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=foobar-title-*&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'1'%3A('0'%3Asize%2C'1'%3A1000)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A'tooltipField%3A%20foobar'))%2C'6'%3A('0'%3AfieldsFromSource%2C'1'%3A!(tooltipField%2CstyleField))%2C'7'%3A('0'%3Asource%2C'1'%3A!(tooltipField%2CstyleField))))&token=1234` + `rootdir/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=foobar-title-*&hasLabels=false&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'1'%3A('0'%3Asize%2C'1'%3A1000)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A'tooltipField%3A%20foobar'))%2C'6'%3A('0'%3AfieldsFromSource%2C'1'%3A!(tooltipField%2CstyleField))%2C'7'%3A('0'%3Asource%2C'1'%3A!(tooltipField%2CstyleField))))&token=1234` ); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 52b9675cdbb39f..b8982042b2365a 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -810,7 +810,11 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource return 'hits'; } - async getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise { + async getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise { const indexPattern = await this.getIndexPattern(); const indexSettings = await loadIndexSettings(indexPattern.title); @@ -847,6 +851,7 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource return `${mvtUrlServicePath}\ ?geometryFieldName=${this._descriptor.geoField}\ &index=${indexPattern.title}\ +&hasLabels=${hasLabels}\ &requestBody=${encodeMvtResponseBody(searchSource.getSearchRequestBody())}\ &token=${refreshToken}`; } diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts index fca72af193ca31..c6f55436efc151 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts @@ -13,7 +13,11 @@ export interface IMvtVectorSource extends IVectorSource { * IMvtVectorSource.getTileUrl returns the tile source URL. * Append refreshToken as a URL parameter to force tile re-fetch on refresh (not required) */ - getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise; + getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise; /* * Tile vector sources can contain multiple layers. For example, elasticsearch _mvt tiles contain the layers "hits", "aggs", and "meta". diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx new file mode 100644 index 00000000000000..295e7c57b7a227 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component } from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; +import { DynamicSizeProperty } from '../../properties/dynamic_size_property'; + +const FONT_SIZE = 10; +const HALF_FONT_SIZE = FONT_SIZE / 2; +const MIN_MARKER_DISTANCE = (FONT_SIZE + 2) / 2; + +const EMPTY_VALUE = ''; + +interface Props { + style: DynamicSizeProperty; +} + +interface State { + label: string; +} + +export class MarkerSizeLegend extends Component { + private _isMounted: boolean = false; + + state: State = { + label: EMPTY_VALUE, + }; + + componentDidMount() { + this._isMounted = true; + this._loadLabel(); + } + + componentDidUpdate() { + this._loadLabel(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadLabel() { + const field = this.props.style.getField(); + if (!field) { + return; + } + const label = await field.getLabel(); + if (this._isMounted && this.state.label !== label) { + this.setState({ label }); + } + } + + _formatValue(value: string | number) { + return value === EMPTY_VALUE ? value : this.props.style.formatField(value); + } + + _renderMarkers() { + const fieldMeta = this.props.style.getRangeFieldMeta(); + const options = this.props.style.getOptions(); + if (!fieldMeta || !options) { + return null; + } + + const circleStyle = { + fillOpacity: 0, + stroke: euiThemeVars.euiTextColor, + strokeWidth: 1, + }; + + const svgHeight = options.maxSize * 2 + HALF_FONT_SIZE + circleStyle.strokeWidth * 2; + const circleCenterX = options.maxSize + circleStyle.strokeWidth; + const circleBottomY = svgHeight - circleStyle.strokeWidth; + + function makeMarker(radius: number, formattedValue: string | number) { + const circleCenterY = circleBottomY - radius; + const circleTopY = circleCenterY - radius; + return ( + + + + {formattedValue} + + + + ); + } + + function getMarkerRadius(percentage: number) { + const delta = options.maxSize - options.minSize; + return percentage * delta + options.minSize; + } + + function getValue(percentage: number) { + // Markers interpolated by area instead of radius to be more consistent with how the human eye+brain perceive shapes + // and their visual relevance + // This function mirrors output of maplibre expression created from DynamicSizeProperty.getMbSizeExpression + const value = Math.pow(percentage * Math.sqrt(fieldMeta!.delta), 2) + fieldMeta!.min; + return fieldMeta!.delta > 3 ? Math.round(value) : value; + } + + const markers = []; + + if (fieldMeta.delta > 0) { + const smallestMarker = makeMarker(options.minSize, this._formatValue(fieldMeta.min)); + markers.push(smallestMarker); + + const markerDelta = options.maxSize - options.minSize; + if (markerDelta > MIN_MARKER_DISTANCE * 3) { + markers.push(makeMarker(getMarkerRadius(0.25), this._formatValue(getValue(0.25)))); + markers.push(makeMarker(getMarkerRadius(0.5), this._formatValue(getValue(0.5)))); + markers.push(makeMarker(getMarkerRadius(0.75), this._formatValue(getValue(0.75)))); + } else if (markerDelta > MIN_MARKER_DISTANCE) { + markers.push(makeMarker(getMarkerRadius(0.5), this._formatValue(getValue(0.5)))); + } + } + + const largestMarker = makeMarker(options.maxSize, this._formatValue(fieldMeta.max)); + markers.push(largestMarker); + + return ( + + {markers} + + ); + } + + render() { + return ( +
+ + + + + + {this.state.label} + + + + + + {this._renderMarkers()} +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap index 9dc0e99669c791..bf239aa40e33a5 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap @@ -1,6 +1,165 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renderLegendDetailRow Should render as range 1`] = ` +exports[`renderLegendDetailRow Should render icon size scale 1`] = ` +
+ + + + + + + foobar_label + + + + + + + + + + + 0_format + + + + + + + 25_format + + + + + + + 100_format + + + + +
+`; + +exports[`renderLegendDetailRow Should render line width simple range 1`] = ` @@ -36,9 +196,10 @@ exports[`renderLegendDetailRow Should render as range 1`] = ` @@ -56,8 +217,9 @@ exports[`renderLegendDetailRow Should render as range 1`] = ` `; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx index 0446b9e30f47b7..9f92d81313da74 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx @@ -20,7 +20,53 @@ import { IField } from '../../../../fields/field'; import { IVectorLayer } from '../../../../layers/vector_layer'; describe('renderLegendDetailRow', () => { - test('Should render as range', async () => { + test('Should render line width simple range', async () => { + const field = { + getLabel: async () => { + return 'foobar_label'; + }, + getName: () => { + return 'foodbar'; + }, + getOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + supportsFieldMetaFromEs: () => { + return true; + }, + supportsFieldMetaFromLocalData: () => { + return true; + }, + } as unknown as IField; + const sizeProp = new DynamicSizeProperty( + { minSize: 0, maxSize: 10, fieldMetaOptions: { isEnabled: true } }, + VECTOR_STYLES.LINE_WIDTH, + field, + {} as unknown as IVectorLayer, + () => { + return (value: RawValue) => value + '_format'; + }, + false + ); + sizeProp.getRangeFieldMeta = () => { + return { + min: 0, + max: 100, + delta: 100, + }; + }; + + const legendRow = sizeProp.renderLegendDetailRow(); + const component = shallow(legendRow); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('Should render icon size scale', async () => { const field = { getLabel: async () => { return 'foobar_label'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx index d8fe8463edba86..83ac50c7b4eaaa 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx @@ -9,6 +9,7 @@ import React from 'react'; import type { Map as MbMap } from '@kbn/mapbox-gl'; import { DynamicStyleProperty } from '../dynamic_style_property'; import { OrdinalLegend } from '../../components/legend/ordinal_legend'; +import { MarkerSizeLegend } from '../../components/legend/marker_size_legend'; import { makeMbClampedNumberExpression } from '../../style_util'; import { FieldFormatter, @@ -141,6 +142,10 @@ export class DynamicSizeProperty extends DynamicStyleProperty; + return this.getStyleName() === VECTOR_STYLES.ICON_SIZE && !this._isSymbolizedAsIcon ? ( + + ) : ( + + ); } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts index 5d4d5bc3ecbfb6..905bc63fb078be 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts @@ -94,9 +94,9 @@ export function makeMbClampedNumberExpression({ ]; } -export function getHasLabel(label: StaticTextProperty | DynamicTextProperty) { +export function getHasLabel(label: StaticTextProperty | DynamicTextProperty): boolean { return label.isDynamic() ? label.isComplete() : (label as StaticTextProperty).getOptions().value != null && - (label as StaticTextProperty).getOptions().value.length; + (label as StaticTextProperty).getOptions().value.length > 0; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index d9a296031b5a16..7ce9673fdc10ec 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -115,6 +115,12 @@ export interface IVectorStyle extends IStyle { mbMap: MbMap, mbSourceId: string ) => boolean; + + /* + * Returns true when "Label" style configuration is complete and map shows a label for layer features. + */ + hasLabels: () => boolean; + arePointsSymbolizedAsCircles: () => boolean; setMBPaintProperties: ({ alpha, @@ -674,14 +680,14 @@ export class VectorStyle implements IVectorStyle { } _getLegendDetailStyleProperties = () => { - const hasLabel = getHasLabel(this._labelStyleProperty); + const hasLabels = this.hasLabels(); return this.getDynamicPropertiesArray().filter((styleProperty) => { const styleName = styleProperty.getStyleName(); if ([VECTOR_STYLES.ICON_ORIENTATION, VECTOR_STYLES.LABEL_TEXT].includes(styleName)) { return false; } - if (!hasLabel && LABEL_STYLES.includes(styleName)) { + if (!hasLabels && LABEL_STYLES.includes(styleName)) { // do not render legend for label styles when there is no label return false; } @@ -768,6 +774,10 @@ export class VectorStyle implements IVectorStyle { return !this._symbolizeAsStyleProperty.isSymbolizedAsIcon(); } + hasLabels() { + return getHasLabel(this._labelStyleProperty); + } + setMBPaintProperties({ alpha, mbMap, diff --git a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts index 2f25dc84fe2244..a86ca84901cd93 100644 --- a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts +++ b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts @@ -55,7 +55,7 @@ export function getFillFilterExpression( ): FilterSpecification { return getFilterExpression( [ - // explicit EXCLUDE_CENTROID_FEATURES filter not needed. Centroids are points and are filtered out by geometry narrowing + // explicit "exclude centroid features" filter not needed. Label features are points and are filtered out by geometry narrowing [ 'any', ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], @@ -73,7 +73,7 @@ export function getLineFilterExpression( ): FilterSpecification { return getFilterExpression( [ - // explicit EXCLUDE_CENTROID_FEATURES filter not needed. Centroids are points and are filtered out by geometry narrowing + // explicit "exclude centroid features" filter not needed. Label features are points and are filtered out by geometry narrowing [ 'any', ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], @@ -94,18 +94,25 @@ const IS_POINT_FEATURE = [ ]; export function getPointFilterExpression( + isSourceGeoJson: boolean, + isESSource: boolean, joinFilter?: FilterSpecification, timesliceMaskConfig?: TimesliceMaskConfig ): FilterSpecification { - return getFilterExpression( - [EXCLUDE_CENTROID_FEATURES, IS_POINT_FEATURE], - joinFilter, - timesliceMaskConfig - ); + const filters: FilterSpecification[] = []; + if (isSourceGeoJson) { + filters.push(EXCLUDE_CENTROID_FEATURES); + } else if (!isSourceGeoJson && isESSource) { + filters.push(['!=', ['get', '_mvt_label_position'], true]); + } + filters.push(IS_POINT_FEATURE); + + return getFilterExpression(filters, joinFilter, timesliceMaskConfig); } export function getLabelFilterExpression( isSourceGeoJson: boolean, + isESSource: boolean, joinFilter?: FilterSpecification, timesliceMaskConfig?: TimesliceMaskConfig ): FilterSpecification { @@ -116,6 +123,8 @@ export function getLabelFilterExpression( // For GeoJSON sources, show label for centroid features or point/multi-point features only. // no explicit isCentroidFeature filter is needed, centroids are points and are included in the geometry filter. filters.push(IS_POINT_FEATURE); + } else if (!isSourceGeoJson && isESSource) { + filters.push(['==', ['get', '_mvt_label_position'], true]); } return getFilterExpression(filters, joinFilter, timesliceMaskConfig); diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts index a45be3cf80ec04..4534c8047409dd 100644 --- a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts @@ -11,7 +11,7 @@ test('Should return elasticsearch vector tile request for aggs tiles', () => { expect( getTileRequest({ layerId: '1', - tileUrl: `/pof/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&gridPrecision=8&requestBody=(_source%3A(excludes%3A!())%2Caggs%3A()%2Cfields%3A!((field%3A'%40timestamp'%2Cformat%3Adate_time)%2C(field%3Atimestamp%2Cformat%3Adate_time)%2C(field%3Autc_time%2Cformat%3Adate_time))%2Cquery%3A(bool%3A(filter%3A!((match_phrase%3A(machine.os.keyword%3Aios))%2C(range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A'2022-04-22T16%3A46%3A00.744Z'%2Clte%3A'2022-04-29T16%3A46%3A05.345Z'))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A'emit(doc%5B!'timestamp!'%5D.value.getHour())%3B')%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A0%2Cstored_fields%3A!('*'))&renderAs=heatmap&token=e8bff005-ccea-464a-ae56-2061b4f8ce68`, + tileUrl: `/pof/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&hasLabels=false&index=kibana_sample_data_logs&gridPrecision=8&requestBody=(_source%3A(excludes%3A!())%2Caggs%3A()%2Cfields%3A!((field%3A'%40timestamp'%2Cformat%3Adate_time)%2C(field%3Atimestamp%2Cformat%3Adate_time)%2C(field%3Autc_time%2Cformat%3Adate_time))%2Cquery%3A(bool%3A(filter%3A!((match_phrase%3A(machine.os.keyword%3Aios))%2C(range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A'2022-04-22T16%3A46%3A00.744Z'%2Clte%3A'2022-04-29T16%3A46%3A05.345Z'))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A'emit(doc%5B!'timestamp!'%5D.value.getHour())%3B')%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A0%2Cstored_fields%3A!('*'))&renderAs=heatmap&token=e8bff005-ccea-464a-ae56-2061b4f8ce68`, x: 3, y: 0, z: 2, @@ -71,6 +71,7 @@ test('Should return elasticsearch vector tile request for aggs tiles', () => { type: 'long', }, }, + with_labels: false, }, }); }); @@ -79,7 +80,7 @@ test('Should return elasticsearch vector tile request for hits tiles', () => { expect( getTileRequest({ layerId: '1', - tileUrl: `http://localhost:5601/pof/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&requestBody=(_source%3A!f%2Cdocvalue_fields%3A!()%2Cquery%3A(bool%3A(filter%3A!((range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A%272022-04-22T16%3A46%3A00.744Z%27%2Clte%3A%272022-04-29T16%3A46%3A05.345Z%27))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A%27emit(doc%5B!%27timestamp!%27%5D.value.getHour())%3B%27)%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A10000%2Cstored_fields%3A!(geo.coordinates))&token=415049b6-bb0a-444a-a7b9-89717db5183c`, + tileUrl: `http://localhost:5601/pof/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&hasLabels=true&index=kibana_sample_data_logs&requestBody=(_source%3A!f%2Cdocvalue_fields%3A!()%2Cquery%3A(bool%3A(filter%3A!((range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A%272022-04-22T16%3A46%3A00.744Z%27%2Clte%3A%272022-04-29T16%3A46%3A05.345Z%27))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A%27emit(doc%5B!%27timestamp!%27%5D.value.getHour())%3B%27)%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A10000%2Cstored_fields%3A!(geo.coordinates))&token=415049b6-bb0a-444a-a7b9-89717db5183c`, x: 0, y: 0, z: 2, @@ -118,6 +119,7 @@ test('Should return elasticsearch vector tile request for hits tiles', () => { }, }, track_total_hits: 10001, + with_labels: true, }, }); }); diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts index f483dfda23409d..c79ef7c64fdd15 100644 --- a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts @@ -35,11 +35,16 @@ export function getTileRequest(tileRequest: TileRequest): { path?: string; body? } const geometryFieldName = searchParams.get('geometryFieldName') as string; + const hasLabels = searchParams.has('hasLabels') + ? searchParams.get('hasLabels') === 'true' + : false; + if (tileRequest.tileUrl.includes(MVT_GETGRIDTILE_API_PATH)) { return getAggsTileRequest({ encodedRequestBody, geometryFieldName, gridPrecision: parseInt(searchParams.get('gridPrecision') as string, 10), + hasLabels, index, renderAs: searchParams.get('renderAs') as RENDER_AS, x: tileRequest.x, @@ -52,6 +57,7 @@ export function getTileRequest(tileRequest: TileRequest): { path?: string; body? return getHitsTileRequest({ encodedRequestBody, geometryFieldName, + hasLabels, index, x: tileRequest.x, y: tileRequest.y, diff --git a/x-pack/plugins/maps/public/locators.ts b/x-pack/plugins/maps/public/locators.ts index 6c5d5a730edf73..7cfdb7a0d3fb19 100644 --- a/x-pack/plugins/maps/public/locators.ts +++ b/x-pack/plugins/maps/public/locators.ts @@ -10,7 +10,12 @@ import rison from 'rison-node'; import type { SerializableRecord } from '@kbn/utility-types'; import { type Filter, isFilterPinned } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import type { LayerDescriptor } from '../common/descriptor_types'; @@ -78,7 +83,7 @@ export class MapsAppLocatorDefinition implements LocatorDefinition !isFilterPinned(f)); @@ -87,7 +92,7 @@ export class MapsAppLocatorDefinition implements LocatorDefinition('_g', queryState, { useHash }, path); + path = setStateToKbnUrl('_g', queryState, { useHash }, path); path = setStateToKbnUrl('_a', appState, { useHash }, path); if (initialLayers && initialLayers.length) { diff --git a/x-pack/plugins/maps/public/maps_vis_type_alias.ts b/x-pack/plugins/maps/public/maps_vis_type_alias.ts index e6dad590b037a1..911e886a8199ee 100644 --- a/x-pack/plugins/maps/public/maps_vis_type_alias.ts +++ b/x-pack/plugins/maps/public/maps_vis_type_alias.ts @@ -7,8 +7,9 @@ import { i18n } from '@kbn/i18n'; import type { VisualizationsSetup, VisualizationStage } from '@kbn/visualizations-plugin/public'; +import type { SimpleSavedObject } from '@kbn/core/public'; import type { SavedObject } from '@kbn/core/types/saved_objects'; -import type { MapSavedObject } from '../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../common/map_saved_object_type'; import { APP_ID, APP_ICON, @@ -38,12 +39,15 @@ export function getMapsVisTypeAlias(visualizations: VisualizationsSetup) { docTypes: [MAP_SAVED_OBJECT_TYPE], searchFields: ['title^3'], toListItem(savedObject: SavedObject) { - const { id, type, attributes } = savedObject as MapSavedObject; + const { id, type, updatedAt, attributes } = + savedObject as SimpleSavedObject; const { title, description } = attributes; + return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: APP_ID, icon: APP_ICON, diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index 5aa8e7877628ab..9278f08bd4d2d6 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -113,6 +113,7 @@ async function findMaps(searchQuery: string) { title: savedObject.attributes.title, description: savedObject.attributes.description, references: savedObject.references, + updatedAt: savedObject.updatedAt, }; }), }; diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts index 8e816c6930fdb3..79d36030558746 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; import { getUiSettings } from '../../../kibana_services'; import { SerializedMapState } from './types'; @@ -15,7 +15,7 @@ export function getInitialRefreshConfig({ globalState = {}, }: { serializedMapState?: SerializedMapState; - globalState: QueryState; + globalState: GlobalQueryStateFromUrl; }) { const uiSettings = getUiSettings(); diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts index da293d5c52d298..fc3754256d659c 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { getUiSettings } from '../../../kibana_services'; import { SerializedMapState } from './types'; @@ -14,7 +14,7 @@ export function getInitialTimeFilters({ globalState, }: { serializedMapState?: SerializedMapState; - globalState: QueryState; + globalState: GlobalQueryStateFromUrl; }) { if (serializedMapState?.timeFilters) { return serializedMapState.timeFilters; diff --git a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts index cab4b98ffd784c..213c1a6cde3ee2 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts @@ -6,13 +6,13 @@ */ import { asyncForEach } from '@kbn/std'; -import { ISavedObjectsRepository } from '@kbn/core/server'; +import type { ISavedObjectsRepository, SavedObject } from '@kbn/core/server'; import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; -import { MapSavedObject, MapSavedObjectAttributes } from '../../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../../common/map_saved_object_type'; export async function findMaps( savedObjectsClient: Pick, - callback: (savedObject: MapSavedObject) => Promise + callback: (savedObject: SavedObject) => Promise ) { let nextPage = 1; let hasMorePages = false; diff --git a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts index ad1c0239963b46..dcbc9c884275da 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { SavedObject } from '@kbn/core/server'; import { asyncForEach } from '@kbn/std'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { DataViewsService } from '@kbn/data-views-plugin/common'; @@ -15,7 +16,7 @@ import { ESSearchSourceDescriptor, LayerDescriptor, } from '../../../common/descriptor_types'; -import { MapSavedObject } from '../../../common/map_saved_object_type'; +import type { MapSavedObjectAttributes } from '../../../common/map_saved_object_type'; import { IndexPatternStats } from './types'; /* @@ -29,7 +30,7 @@ export class IndexPatternStatsCollector { this._indexPatternsService = indexPatternService; } - async push(savedObject: MapSavedObject) { + async push(savedObject: SavedObject) { let layerList: LayerDescriptor[] = []; try { const { attributes } = injectReferences(savedObject); diff --git a/x-pack/plugins/maps/server/mvt/mvt_routes.ts b/x-pack/plugins/maps/server/mvt/mvt_routes.ts index 8af26548b1d28d..6fd7374fb69c18 100644 --- a/x-pack/plugins/maps/server/mvt/mvt_routes.ts +++ b/x-pack/plugins/maps/server/mvt/mvt_routes.ts @@ -44,6 +44,7 @@ export function initMVTRoutes({ }), query: schema.object({ geometryFieldName: schema.string(), + hasLabels: schema.boolean(), requestBody: schema.string(), index: schema.string(), token: schema.maybe(schema.string()), @@ -65,6 +66,7 @@ export function initMVTRoutes({ tileRequest = getHitsTileRequest({ encodedRequestBody: query.requestBody as string, geometryFieldName: query.geometryFieldName as string, + hasLabels: query.hasLabels as boolean, index: query.index as string, x, y, @@ -102,6 +104,7 @@ export function initMVTRoutes({ }), query: schema.object({ geometryFieldName: schema.string(), + hasLabels: schema.boolean(), requestBody: schema.string(), index: schema.string(), renderAs: schema.string(), @@ -126,6 +129,7 @@ export function initMVTRoutes({ encodedRequestBody: query.requestBody as string, geometryFieldName: query.geometryFieldName as string, gridPrecision: parseInt(query.gridPrecision, 10), + hasLabels: query.hasLabels as boolean, index: query.index as string, renderAs: query.renderAs as RENDER_AS, x, diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 7b98eefe0ab248..a5b94836e5a1db 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -54,6 +54,8 @@ export const ML_PAGES = { OVERVIEW: 'overview', AIOPS: 'aiops', AIOPS_EXPLAIN_LOG_RATE_SPIKES: 'aiops/explain_log_rate_spikes', + AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT: 'aiops/explain_log_rate_spikes_index_select', + AIOPS_SINGLE_ENDPOINT_STREAMING_DEMO: 'aiops/single_endpoint_streaming_demo', } as const; export type MlPages = typeof ML_PAGES[keyof typeof ML_PAGES]; diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts index 92c0c1d06ef938..3d7dda658a0ba3 100644 --- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts @@ -7,11 +7,9 @@ import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { RuntimeMappings } from './fields'; import { EsErrorBody } from '../util/errors'; import { ANALYSIS_CONFIG_TYPE } from '../constants/data_frame_analytics'; -import { DATA_FRAME_TASK_STATE } from '../constants/data_frame_analytics'; export interface DeleteDataFrameAnalyticsWithIndexStatus { success: boolean; @@ -57,69 +55,90 @@ export interface ClassificationAnalysis { classification: Classification; } -interface GenericAnalysis { - [key: string]: Record; +export type AnalysisConfig = estypes.MlDataframeAnalysisContainer; +export interface DataFrameAnalyticsConfig + extends Omit { + analyzed_fields?: estypes.MlDataframeAnalysisAnalyzedFields; } -export type AnalysisConfig = - | OutlierAnalysis - | RegressionAnalysis - | ClassificationAnalysis - | GenericAnalysis; - -export interface DataFrameAnalyticsConfig { - id: DataFrameAnalyticsId; +export interface UpdateDataFrameAnalyticsConfig { + allow_lazy_start?: string; description?: string; - dest: { - index: IndexName; - results_field: string; - }; - source: { - index: IndexName | IndexName[]; - query?: estypes.QueryDslQueryContainer; - runtime_mappings?: RuntimeMappings; - }; - analysis: AnalysisConfig; - analyzed_fields?: { - includes?: string[]; - excludes?: string[]; - }; - model_memory_limit: string; + model_memory_limit?: string; max_num_threads?: number; - create_time: number; - version: string; - allow_lazy_start?: boolean; } export type DataFrameAnalysisConfigType = typeof ANALYSIS_CONFIG_TYPE[keyof typeof ANALYSIS_CONFIG_TYPE]; -export type DataFrameTaskStateType = - typeof DATA_FRAME_TASK_STATE[keyof typeof DATA_FRAME_TASK_STATE]; +export type DataFrameTaskStateType = estypes.MlDataframeState | 'analyzing' | 'reindexing'; + +export interface DataFrameAnalyticsStats extends Omit { + failure_reason?: string; + state: DataFrameTaskStateType; +} + +export type DfAnalyticsExplainResponse = estypes.MlExplainDataFrameAnalyticsResponse; + +export interface PredictedClass { + predicted_class: string; + count: number; +} +export interface ConfusionMatrix { + actual_class: string; + actual_class_doc_count: number; + predicted_classes: PredictedClass[]; + other_predicted_class_doc_count: number; +} + +export interface RocCurveItem { + fpr: number; + threshold: number; + tpr: number; +} -interface ProgressSection { - phase: string; - progress_percent: number; +interface EvalClass { + class_name: string; + value: number; +} +export interface ClassificationEvaluateResponse { + classification: { + multiclass_confusion_matrix?: { + confusion_matrix: ConfusionMatrix[]; + }; + recall?: { + classes: EvalClass[]; + avg_recall: number; + }; + accuracy?: { + classes: EvalClass[]; + overall_accuracy: number; + }; + auc_roc?: { + curve?: RocCurveItem[]; + value: number; + }; + }; } -export interface DataFrameAnalyticsStats { - assignment_explanation?: string; - id: DataFrameAnalyticsId; - memory_usage?: { - timestamp?: string; - peak_usage_bytes: number; - status: string; +export interface EvaluateMetrics { + classification: { + accuracy?: object; + recall?: object; + multiclass_confusion_matrix?: object; + auc_roc?: { include_curve: boolean; class_name: string }; }; - node?: { - attributes: Record; - ephemeral_id: string; - id: string; - name: string; - transport_address: string; + regression: { + r_squared: object; + mse: object; + msle: object; + huber: object; }; - progress: ProgressSection[]; - failure_reason?: string; - state: DataFrameTaskStateType; +} + +export interface FieldSelectionItem + extends Omit { + mapping_types?: string[]; } export interface AnalyticsMapNodeElement { @@ -146,30 +165,14 @@ export interface AnalyticsMapReturnType { error: null | any; } -export interface FeatureProcessor { - frequency_encoding: { - feature_name: string; - field: string; - frequency_map: Record; - }; - multi_encoding: { - processors: any[]; - }; - n_gram_encoding: { - feature_prefix?: string; - field: string; - length?: number; - n_grams: number[]; - start?: number; - }; - one_hot_encoding: { - field: string; - hot_map: string; - }; - target_mean_encoding: { - default_value: number; - feature_name: string; - field: string; - target_map: Record; +export type FeatureProcessor = estypes.MlDataframeAnalysisFeatureProcessor; + +export interface TrackTotalHitsSearchResponse { + hits: { + total: { + value: number; + relation: string; + }; + hits: any[]; }; } diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index 0d5cb7aeddd814..742486c78b5bf8 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -63,7 +63,9 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.DATA_VISUALIZER_FILE | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT | typeof ML_PAGES.AIOPS - | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, + | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES + | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT + | typeof ML_PAGES.AIOPS_SINGLE_ENDPOINT_STREAMING_DEMO, MlGenericUrlPageState | undefined >; diff --git a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx index 473525d40ca9a7..39fa5272799fd7 100644 --- a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx @@ -5,44 +5,32 @@ * 2.0. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { ExplainLogRateSpikesSpec } from '@kbn/aiops-plugin/public'; -import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { ExplainLogRateSpikes } from '@kbn/aiops-plugin/public'; + +import { useMlContext } from '../contexts/ml'; +import { useMlKibana } from '../contexts/kibana'; import { HelpMenu } from '../components/help_menu'; import { MlPageHeader } from '../components/page_header'; export const ExplainLogRateSpikesPage: FC = () => { - useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const { - services: { docLinks, aiops }, + services: { docLinks }, } = useMlKibana(); - const [ExplainLogRateSpikes, setExplainLogRateSpikes] = useState( - null - ); - - useEffect(() => { - if (aiops !== undefined) { - const { getExplainLogRateSpikesComponent } = aiops; - getExplainLogRateSpikesComponent().then(setExplainLogRateSpikes); - } - }, []); + const context = useMlContext(); return ( <> - {ExplainLogRateSpikes !== null ? ( - <> - - - - - - ) : null} + + + + ); diff --git a/x-pack/plugins/ml/public/application/aiops/single_endpoint_streaming_demo.tsx b/x-pack/plugins/ml/public/application/aiops/single_endpoint_streaming_demo.tsx new file mode 100644 index 00000000000000..fa2bc7f7051e47 --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/single_endpoint_streaming_demo.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { SingleEndpointStreamingDemo } from '@kbn/aiops-plugin/public'; +import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { HelpMenu } from '../components/help_menu'; + +import { MlPageHeader } from '../components/page_header'; + +export const SingleEndpointStreamingDemoPage: FC = () => { + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const { + services: { docLinks }, + } = useMlKibana(); + + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx index ac67dbe35d9ec4..773278074cb726 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx @@ -694,11 +694,7 @@ export const LinksMenuUI = (props: LinksMenuProps) => { ]); return ( - + ); }; diff --git a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx index 84474e85330d6b..250dbc52cfd9cc 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx @@ -229,13 +229,22 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { items: [ { id: 'explainlogratespikes', - pathId: ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, + pathId: ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT, name: i18n.translate('xpack.ml.navMenu.explainLogRateSpikesLinkText', { defaultMessage: 'Explain log rate spikes', }), disabled: disableLinks, testSubj: 'mlMainTab explainLogRateSpikes', }, + { + id: 'singleEndpointStreamingDemo', + pathId: ML_PAGES.AIOPS_SINGLE_ENDPOINT_STREAMING_DEMO, + name: i18n.translate('xpack.ml.navMenu.singleEndpointStreamingDemoLinkText', { + defaultMessage: 'Single endpoint streaming demo', + }), + disabled: disableLinks, + testSubj: 'mlMainTab singleEndpointStreamingDemo', + }, ], }); } diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts index 2a8806bf3ff384..8b755b02f99b90 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts @@ -6,9 +6,9 @@ */ import React from 'react'; -import { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; -import { SavedSearchSavedObject } from '../../../../common/types/kibana'; -import { MlServicesContext } from '../../app'; +import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; +import type { SavedSearchSavedObject } from '../../../../common/types/kibana'; +import type { MlServicesContext } from '../../app'; export interface MlContextValue { combinedQuery: any; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts index 0cd4d190ebbbd6..aa83ce0a1f4ad2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts @@ -13,15 +13,18 @@ describe('Data Frame Analytics: Analytics utils', () => { expect(getAnalysisType(outlierAnalysis)).toBe('outlier_detection'); const regressionAnalysis = { regression: {} }; + // @ts-expect-error incomplete regression analysis expect(getAnalysisType(regressionAnalysis)).toBe('regression'); // test against a job type that does not exist yet. const otherAnalysis = { other: {} }; + // @ts-expect-error unkown analysis type expect(getAnalysisType(otherAnalysis)).toBe('other'); // if the analysis object has a shape that is not just a single property, // the job type will be returned as 'unknown'. const unknownAnalysis = { outlier_detection: {}, regression: {} }; + // @ts-expect-error unkown analysis type expect(getAnalysisType(unknownAnalysis)).toBe('unknown'); }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index c2c2563c5ba7c8..064416cd722d11 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -12,6 +12,11 @@ import { cloneDeep } from 'lodash'; import { ml } from '../../services/ml_api_service'; import { Dictionary } from '../../../../common/types/common'; import { extractErrorMessage } from '../../../../common/util/errors'; +import { + ClassificationEvaluateResponse, + EvaluateMetrics, + TrackTotalHitsSearchResponse, +} from '../../../../common/types/data_frame_analytics'; import { SavedSearchQuery } from '../../contexts/ml'; import { AnalysisConfig, @@ -106,23 +111,6 @@ export enum INDEX_STATUS { ERROR, } -export interface FieldSelectionItem { - name: string; - mappings_types?: string[]; - is_included: boolean; - is_required: boolean; - feature_type?: string; - reason?: string; -} - -export interface DfAnalyticsExplainResponse { - field_selection?: FieldSelectionItem[]; - memory_estimation: { - expected_memory_without_disk: string; - expected_memory_with_disk: string; - }; -} - export interface Eval { mse: number | string; msle: number | string; @@ -148,49 +136,6 @@ export interface RegressionEvaluateResponse { }; } -export interface PredictedClass { - predicted_class: string; - count: number; -} - -export interface ConfusionMatrix { - actual_class: string; - actual_class_doc_count: number; - predicted_classes: PredictedClass[]; - other_predicted_class_doc_count: number; -} - -export interface RocCurveItem { - fpr: number; - threshold: number; - tpr: number; -} - -interface EvalClass { - class_name: string; - value: number; -} - -export interface ClassificationEvaluateResponse { - classification: { - multiclass_confusion_matrix?: { - confusion_matrix: ConfusionMatrix[]; - }; - recall?: { - classes: EvalClass[]; - avg_recall: number; - }; - accuracy?: { - classes: EvalClass[]; - overall_accuracy: number; - }; - auc_roc?: { - curve?: RocCurveItem[]; - value: number; - }; - }; -} - interface LoadEvaluateResult { success: boolean; eval: RegressionEvaluateResponse | ClassificationEvaluateResponse | null; @@ -279,13 +224,6 @@ export const isClassificationEvaluateResponse = ( ); }; -export interface UpdateDataFrameAnalyticsConfig { - allow_lazy_start?: string; - description?: string; - model_memory_limit?: string; - max_num_threads?: number; -} - export enum REFRESH_ANALYTICS_LIST_STATE { ERROR = 'error', IDLE = 'idle', @@ -451,21 +389,6 @@ export enum REGRESSION_STATS { HUBER = 'huber', } -interface EvaluateMetrics { - classification: { - accuracy?: object; - recall?: object; - multiclass_confusion_matrix?: object; - auc_roc?: { include_curve: boolean; class_name: string }; - }; - regression: { - r_squared: object; - mse: object; - msle: object; - huber: object; - }; -} - interface LoadEvalDataConfig { isTraining?: boolean; index: string; @@ -548,16 +471,6 @@ export const loadEvalData = async ({ } }; -interface TrackTotalHitsSearchResponse { - hits: { - total: { - value: number; - relation: string; - }; - hits: any[]; - }; -} - interface LoadDocsCountConfig { ignoreDefaultQuery?: boolean; isTraining?: boolean; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 89c05643f0dc8f..3ab82daa6b1f3a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -96,7 +96,7 @@ export const sortExplorationResultsFields = ( if (isClassificationAnalysis(jobConfig.analysis) || isRegressionAnalysis(jobConfig.analysis)) { const dependentVariable = getDependentVar(jobConfig.analysis); - const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis, true); + const predictedField = getPredictedFieldName(resultsField!, jobConfig.analysis, true); if (a === `${resultsField}.is_training`) { return -1; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 2fb0daa1ed45e4..f47b5b66f49446 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -5,13 +5,7 @@ * 2.0. */ -export type { - UpdateDataFrameAnalyticsConfig, - IndexPattern, - RegressionEvaluateResponse, - Eval, - SearchQuery, -} from './analytics'; +export type { IndexPattern, RegressionEvaluateResponse, Eval, SearchQuery } from './analytics'; export { getAnalysisType, getDependentVar, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index d98940588f48fe..f4a9eb0d5c0a80 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -12,7 +12,7 @@ import { isEqual } from 'lodash'; import { LEFT_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { FieldSelectionItem } from '../../../../common/analytics'; +import { FieldSelectionItem } from '../../../../../../../common/types/data_frame_analytics'; // @ts-ignore could not find declaration file import { CustomSelectionTable } from '../../../../../components/custom_selection_table'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index b4f55bcae09472..758fd01a133c60 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -28,10 +28,10 @@ import { ANALYSIS_CONFIG_TYPE, TRAINING_PERCENT_MIN, TRAINING_PERCENT_MAX, - FieldSelectionItem, } from '../../../../common/analytics'; import { getScatterplotMatrixLegendType } from '../../../../common/get_scatterplot_matrix_legend_type'; import { RuntimeMappings as RuntimeMappingsType } from '../../../../../../../common/types/fields'; +import { FieldSelectionItem } from '../../../../../../../common/types/data_frame_analytics'; import { isRuntimeMappings, isRuntimeField, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts index 7c83b0af15107a..ca334a58b36c2e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts @@ -7,19 +7,15 @@ import { ml } from '../../../../../services/ml_api_service'; import { extractErrorProperties } from '../../../../../../../common/util/errors'; -import { DfAnalyticsExplainResponse, FieldSelectionItem } from '../../../../common/analytics'; +import { + DfAnalyticsExplainResponse, + FieldSelectionItem, +} from '../../../../../../../common/types/data_frame_analytics'; import { getJobConfigFromFormState, State, } from '../../../analytics_management/hooks/use_create_analytics_form/state'; -export interface FetchExplainDataReturnType { - success: boolean; - expectedMemory: string; - fieldSelection: FieldSelectionItem[]; - errorMessage: string; -} - export const fetchExplainData = async (formState: State['form']) => { const jobConfig = getJobConfigFromFormState(formState); let errorMessage = ''; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx index 31b7db66f81ae9..c983511f80393e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx @@ -14,7 +14,7 @@ import { EuiPopover, EuiText, } from '@elastic/eui'; -import { ConfusionMatrix } from '../../../../common/analytics'; +import { ConfusionMatrix } from '../../../../../../../common/types/data_frame_analytics'; const COL_INITIAL_WIDTH = 165; // in pixels diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 97ab582832b64a..8ba780a3e512ad 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -119,7 +119,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se columns.map(({ id }: { id: string }) => id) ); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const isTraining = isTrainingFilter(searchQuery, resultsField); const { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx index 3ca1f65cf2ecc5..e3f92c36507c62 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n'; import { LEGEND_TYPES } from '../../../../../components/vega_chart/common'; -import { RocCurveItem } from '../../../../common/analytics'; +import { RocCurveItem } from '../../../../../../../common/types/data_frame_analytics'; const GRAY = euiPaletteGray(1)[0]; const BASELINE = 'baseline'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts index 2a75acf823e881..c51f5bf3e9665a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts @@ -9,13 +9,15 @@ import { useState, useEffect } from 'react'; import { isClassificationEvaluateResponse, - ClassificationEvaluateResponse, - ConfusionMatrix, ResultsSearchQuery, ANALYSIS_CONFIG_TYPE, ClassificationMetricItem, } from '../../../../common/analytics'; import { isKeywordAndTextType } from '../../../../common/fields'; +import { + ClassificationEvaluateResponse, + ConfusionMatrix, +} from '../../../../../../../common/types/data_frame_analytics'; import { getDependentVar, @@ -78,7 +80,7 @@ export const useConfusionMatrix = ( let requiresKeyword = false; const dependentVariable = getDependentVar(jobConfig.analysis); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const isTraining = isTrainingFilter(searchQuery, resultsField); try { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts index 20521258cd3746..f83f9f9f31e0fb 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts @@ -10,10 +10,10 @@ import { useState, useEffect } from 'react'; import { isClassificationEvaluateResponse, ResultsSearchQuery, - RocCurveItem, ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; import { isKeywordAndTextType } from '../../../../common/fields'; +import { RocCurveItem } from '../../../../../../../common/types/data_frame_analytics'; import { getDependentVar, @@ -58,7 +58,7 @@ export const useRocCurve = ( setIsLoading(true); const dependentVariable = getDependentVar(jobConfig.analysis); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const newRocCurveData: RocCurveDataRow[] = []; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 48477acfe7be80..17453dd87b0d03 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -170,7 +170,7 @@ export const ExplorationPageWrapper: FC = ({ indexPattern={indexPattern} setSearchQuery={searchQueryUpdateHandler} query={query} - filters={getFilters(jobConfig.dest.results_field)} + filters={getFilters(jobConfig.dest.results_field!)} /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index c0590fd80a5d5e..593ef5465d196a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -57,7 +57,7 @@ export const useExplorationResults = ( const columns: EuiDataGridColumn[] = []; if (jobConfig !== undefined) { - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const { fieldTypes } = getIndexFields(jobConfig, needsDestIndexFields); columns.push( ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField).sort((a: any, b: any) => diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index 45653209cdb8a2..920023c23a2bd4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -55,7 +55,7 @@ export const useOutlierData = ( const resultsField = jobConfig.dest.results_field; const { fieldTypes } = getIndexFields(jobConfig, needsDestIndexFields); newColumns.push( - ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField).sort((a: any, b: any) => + ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField!).sort((a: any, b: any) => sortExplorationResultsFields(a.id, b.id, jobConfig) ) ); @@ -135,7 +135,9 @@ export const useOutlierData = ( const colorRange = useColorRange( COLOR_RANGE.BLUE, COLOR_RANGE_SCALE.INFLUENCER, - jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, dataGrid.tableItems) : 1 + jobConfig !== undefined + ? getFeatureCount(jobConfig.dest.results_field!, dataGrid.tableItems) + : 1 ); const renderCellValue = useRenderCellValue( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx index 6d5417db246073..1249b736960d89 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx @@ -75,7 +75,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const dependentVariable = getDependentVar(jobConfig.analysis); const predictionFieldName = getPredictionFieldName(jobConfig.analysis); // default is 'ml' - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field ?? 'ml'; const loadGeneralizationData = async (ignoreDefaultQuery: boolean = true) => { setIsLoadingGeneralization(true); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx index 766f1bda64d5e2..3b8d3ed5460ff7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx @@ -34,10 +34,8 @@ import { MemoryInputValidatorResult, } from '../../../../../../../common/util/validators'; import { DATA_FRAME_TASK_STATE } from '../analytics_list/common'; -import { - useRefreshAnalyticsList, - UpdateDataFrameAnalyticsConfig, -} from '../../../../common/analytics'; +import { useRefreshAnalyticsList } from '../../../../common/analytics'; +import { UpdateDataFrameAnalyticsConfig } from '../../../../../../../common/types/data_frame_analytics'; import { EditAction } from './use_edit_action'; @@ -51,7 +49,9 @@ export const EditActionFlyout: FC> = ({ closeFlyout, item } const [allowLazyStart, setAllowLazyStart] = useState(initialAllowLazyStart); const [description, setDescription] = useState(config.description || ''); - const [modelMemoryLimit, setModelMemoryLimit] = useState(config.model_memory_limit); + const [modelMemoryLimit, setModelMemoryLimit] = useState( + config.model_memory_limit + ); const [mmlValidationError, setMmlValidationError] = useState(); const [maxNumThreads, setMaxNumThreads] = useState(config.max_num_threads); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx index 3f7072fba4040a..2d072d1aecc1fc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx @@ -75,7 +75,7 @@ export const ExpandedRow: FC = ({ item }) => { const dependentVariable = getDependentVar(item.config.analysis); const predictionFieldName = getPredictionFieldName(item.config.analysis); // default is 'ml' - const resultsField = item.config.dest.results_field; + const resultsField = item.config.dest.results_field ?? 'ml'; const jobIsCompleted = isCompletedAnalyticsJob(item.stats); const isRegressionJob = isRegressionAnalysis(item.config.analysis); const analysisType = getAnalysisType(item.config.analysis); @@ -232,8 +232,8 @@ export const ExpandedRow: FC = ({ item }) => { moment(item.config.create_time).unix() * 1000 ), }, - { title: 'model_memory_limit', description: item.config.model_memory_limit }, - { title: 'version', description: item.config.version }, + { title: 'model_memory_limit', description: item.config.model_memory_limit ?? '' }, + { title: 'version', description: item.config.version ?? '' }, ], position: 'left', dataTestSubj: 'mlAnalyticsTableRowDetailsSection stats', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 3077f0fb387260..efa1f58ecddc06 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -43,6 +43,7 @@ enum TASK_STATE_COLOR { started = 'primary', starting = 'primary', stopped = 'hollow', + stopping = 'hollow', } export const getTaskStateBadge = ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 5559e7db2d631d..58a471b4e72468 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -193,6 +193,7 @@ export const validateAdvancedEditor = (state: State): State => { dependentVariableEmpty = dependentVariableName === ''; if ( !dependentVariableEmpty && + Array.isArray(analyzedFields) && analyzedFields.length > 0 && !analyzedFields.includes(dependentVariableName) ) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts index c51ccf1e20d8d9..c27137fca9519a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts @@ -82,6 +82,7 @@ describe('useCreateAnalyticsForm', () => { expect(jobConfig?.dest?.index).toBe('the-destination-index'); expect(jobConfig?.source?.index).toBe('the-source-index'); expect(jobConfig?.analyzed_fields?.includes).toStrictEqual([]); + // @ts-ignore property 'excludes' does not exist expect(typeof jobConfig?.analyzed_fields?.excludes).toBe('undefined'); // test the conversion of comma-separated Kibana index patterns to ES array based index patterns diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 0b2cb8fcfc7168..ca54c552f8ebfb 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -370,7 +370,9 @@ export function getFormStateFromJobConfig( runtimeMappings: analyticsJobConfig.source.runtime_mappings, modelMemoryLimit: analyticsJobConfig.model_memory_limit, maxNumThreads: analyticsJobConfig.max_num_threads, - includes: analyticsJobConfig.analyzed_fields?.includes ?? [], + includes: Array.isArray(analyticsJobConfig.analyzed_fields?.includes) + ? analyticsJobConfig.analyzed_fields?.includes + : [], jobConfigQuery: analyticsJobConfig.source.query || defaultSearchQuery, }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 41a8ae4eeba922..cddc4fcd092dcd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -333,8 +333,8 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SWITCH_TO_FORM }); }; - const setEstimatedModelMemoryLimit = (value: State['estimatedModelMemoryLimit']) => { - dispatch({ type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT, value }); + const setEstimatedModelMemoryLimit = (value: State['estimatedModelMemoryLimit'] | undefined) => { + dispatch({ type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT, value: value ?? '' }); }; const setJobClone = async (cloneJob: DeepReadonly) => { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts index 1c2598477064f0..e0324a261e57d4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts @@ -15,6 +15,7 @@ describe('get_analytics', () => { const mockResponse: GetDataFrameAnalyticsStatsResponseOk = { count: 2, data_frame_analytics: [ + // @ts-expect-error test response missing expected properties { id: 'outlier-cloudwatch', state: DATA_FRAME_TASK_STATE.STOPPED, @@ -37,6 +38,7 @@ describe('get_analytics', () => { }, ], }, + // @ts-expect-error test response missing expected properties { id: 'reg-gallery', state: DATA_FRAME_TASK_STATE.FAILED, diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index 54aedb4a718574..38ace0233cbb84 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -59,7 +59,7 @@ export const AIOPS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', { defaultMessage: 'AIOps', }), - href: '/aiops', + href: '/aiops/explain_log_rate_spikes_index_select', }); export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx index ca670df258a6a6..5fac891a79675b 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx @@ -37,7 +37,7 @@ export const explainLogRateSpikesRouteFactory = ( getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), { - text: i18n.translate('xpack.ml.AiopsBreadcrumbs.explainLogRateSpikesLabel', { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel', { defaultMessage: 'Explain log rate spikes', }), }, diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts index f2b192a4cd0976..10f0eba1adeda2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts @@ -6,3 +6,4 @@ */ export * from './explain_log_rate_spikes'; +export * from './single_endpoint_streaming_demo'; diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/single_endpoint_streaming_demo.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/single_endpoint_streaming_demo.tsx new file mode 100644 index 00000000000000..636357518d0d03 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/single_endpoint_streaming_demo.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { parse } from 'query-string'; + +import { i18n } from '@kbn/i18n'; + +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; + +import { NavigateToPath } from '../../../contexts/kibana'; + +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; +import { SingleEndpointStreamingDemoPage as Page } from '../../../aiops/single_endpoint_streaming_demo'; + +import { checkBasicLicense } from '../../../license'; +import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; +import { cacheDataViewsContract } from '../../../util/index_utils'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; + +export const singleEndpointStreamingDemoRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'single_endpoint_streaming_demo', + path: '/aiops/single_endpoint_streaming_demo', + title: i18n.translate('xpack.ml.aiops.singleEndpointStreamingDemo.docTitle', { + defaultMessage: 'Single endpoint streaming demo', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.singleEndpointStreamingDemoLabel', { + defaultMessage: 'Single endpoint streaming demo', + }), + }, + ], + disabled: !AIOPS_ENABLED, +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); + const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx index d1d547ca8bc909..5ea3bfa9d35ebc 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx @@ -50,6 +50,16 @@ const getDataVisBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) }, ]; +const getExplainLogRateSpikesBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDateViewLabel', { + defaultMessage: 'Data View', + }), + }, +]; + export const indexOrSearchRouteFactory = ( navigateToPath: NavigateToPath, basePath: string @@ -86,6 +96,26 @@ export const dataVizIndexOrSearchRouteFactory = ( breadcrumbs: getDataVisBreadcrumbs(navigateToPath, basePath), }); +export const explainLogRateSpikesIndexOrSearchRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'data_view_explain_log_rate_spikes', + path: '/aiops/explain_log_rate_spikes_index_select', + title: i18n.translate('xpack.ml.selectDataViewLabel', { + defaultMessage: 'Select Data View', + }), + render: (props, deps) => ( + + ), + breadcrumbs: getExplainLogRateSpikesBreadcrumbs(navigateToPath, basePath), +}); + const PageWrapper: FC = ({ nextStepPath, deps, mode }) => { const { services: { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index e4deb90d81073b..479f8c50ae035f 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -10,12 +10,10 @@ import { http } from '../http_service'; import { basePath } from '.'; import type { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import type { ValidateAnalyticsJobResponse } from '../../../../common/constants/validation'; -import type { - DataFrameAnalyticsConfig, - UpdateDataFrameAnalyticsConfig, -} from '../../data_frame_analytics/common'; +import type { DataFrameAnalyticsConfig } from '../../data_frame_analytics/common'; import type { DeepPartial } from '../../../../common/types/common'; import type { NewJobCapsResponse } from '../../../../common/types/fields'; +import type { UpdateDataFrameAnalyticsConfig } from '../../../../common/types/data_frame_analytics'; import type { JobMessage } from '../../../../common/types/audit_message'; import type { DeleteDataFrameAnalyticsWithIndexStatus, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts index 13f07d8c88770b..7d780559fb47dc 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts @@ -6,7 +6,7 @@ */ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - +import { i18n } from '@kbn/i18n'; import { InferenceBase, InferResponse } from '../inference_base'; import { getGeneralInputComponent } from '../text_input'; import { getNerOutputComponent } from './ner_output'; @@ -52,7 +52,10 @@ export class NerInference extends InferenceBase { } public getInputComponent(): JSX.Element { - return getGeneralInputComponent(this); + const placeholder = i18n.translate('xpack.ml.trainedModels.testModelsFlyout.ner.inputText', { + defaultMessage: 'Enter a phrase to test', + }); + return getGeneralInputComponent(this, placeholder); } public getOutputComponent(): JSX.Element { diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts index bb4feaffffb388..b9c1c724ca3485 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts @@ -55,9 +55,10 @@ export class FillMaskInference extends InferenceBase public getInputComponent(): JSX.Element { const placeholder = i18n.translate( - 'xpack.ml.trainedModels.testModelsFlyout.langIdent.inputText', + 'xpack.ml.trainedModels.testModelsFlyout.fillMask.inputText', { - defaultMessage: 'Mask token: [MASK]. e.g. Paris is the [MASK] of France.', + defaultMessage: + 'Enter a phrase to test. Use [MASK] as a placeholder for the missing words.', } ); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts index a56d4a3598a66d..155b696fa7665a 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { InferenceBase, InferenceType } from '../inference_base'; import { processResponse } from './common'; import { getGeneralInputComponent } from '../text_input'; @@ -44,7 +45,13 @@ export class LangIdentInference extends InferenceBase } public getInputComponent(): JSX.Element { - return getGeneralInputComponent(this); + const placeholder = i18n.translate( + 'xpack.ml.trainedModels.testModelsFlyout.textEmbedding.inputText', + { + defaultMessage: 'Enter a phrase to test', + } + ); + return getGeneralInputComponent(this, placeholder); } public getOutputComponent(): JSX.Element { diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index 295dbaebbbae60..b36029329c0879 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -86,6 +86,8 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.DATA_VISUALIZER_INDEX_SELECT: case ML_PAGES.AIOPS: case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES: + case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT: + case ML_PAGES.AIOPS_SINGLE_ENDPOINT_STREAMING_DEMO: case ML_PAGES.OVERVIEW: case ML_PAGES.SETTINGS: case ML_PAGES.FILTER_LISTS_MANAGE: diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts index 894354a0113fc6..d4076a7cf496ae 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts @@ -78,7 +78,6 @@ export class AnalyticsManager { async setJobStats() { try { const jobStats = await this.getAnalyticsStats(); - // @ts-expect-error @elastic-elasticsearch Data frame types incomplete this.jobStats = jobStats; } catch (error) { // eslint-disable-next-line diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts index 9cd8b67be2a6d3..517f3cadf3b187 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts @@ -239,14 +239,16 @@ async function getValidationCheckMessages( let analysisFieldsEmpty = false; const fieldLimit = - analyzedFields.length <= MINIMUM_NUM_FIELD_FOR_CHECK + Array.isArray(analyzedFields) && analyzedFields.length <= MINIMUM_NUM_FIELD_FOR_CHECK ? analyzedFields.length : MINIMUM_NUM_FIELD_FOR_CHECK; - let aggs = analyzedFields.slice(0, fieldLimit).reduce((acc, curr) => { - acc[curr] = { missing: { field: curr } }; - return acc; - }, {} as any); + let aggs = Array.isArray(analyzedFields) + ? analyzedFields.slice(0, fieldLimit).reduce((acc, curr) => { + acc[curr] = { missing: { field: curr } }; + return acc; + }, {} as any) + : {}; if (depVar !== '') { const depVarAgg = { @@ -344,10 +346,18 @@ async function getValidationCheckMessages( ); messages.push(...regressionAndClassificationMessages); - if (analyzedFields.length && analyzedFields.length > INCLUDED_FIELDS_THRESHOLD) { + if ( + Array.isArray(analyzedFields) && + analyzedFields.length && + analyzedFields.length > INCLUDED_FIELDS_THRESHOLD + ) { analysisFieldsNumHigh = true; } else { - if (analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && analyzedFields.length < 1) { + if ( + analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && + Array.isArray(analyzedFields) && + analyzedFields.length < 1 + ) { lowFieldCountWarningMessage.text = i18n.translate( 'xpack.ml.models.dfaValidation.messages.lowFieldCountOutlierWarningText', { @@ -358,6 +368,7 @@ async function getValidationCheckMessages( messages.push(lowFieldCountWarningMessage); } else if ( analysisType !== ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && + Array.isArray(analyzedFields) && analyzedFields.length < 2 ) { lowFieldCountWarningMessage.text = i18n.translate( @@ -446,9 +457,12 @@ export async function validateAnalyticsJob( client: IScopedClusterClient, job: DataFrameAnalyticsConfig ) { + const includedFields = ( + Array.isArray(job?.analyzed_fields?.includes) ? job?.analyzed_fields?.includes : [] + ) as string[]; const messages = await getValidationCheckMessages( client.asCurrentUser, - job?.analyzed_fields?.includes || [], + includedFields, job.analysis, job.source ); diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json index 35fc14e23624fd..fa87299dfb464c 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in successful authentication events. This can be due to password spraying, user enumeration or brute force activity.", + "description": "Security: Authentication - Looks for an unusually large spike in successful authentication events. This can be due to password spraying, user enumeration, or brute force activity.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json index cdf219152c7fdf..9f2f10973a35bd 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in successful authentication events from a particular source IP address. This can be due to password spraying, user enumeration or brute force activity.", + "description": "Security: Authentication - Looks for an unusually large spike in successful authentication events from a particular source IP address. This can be due to password spraying, user enumeration, or brute force activity.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json index cde52bf7d33ccd..c74dff5257864b 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in authentication failure events. This can be due to password spraying, user enumeration or brute force activity and may be a precursor to account takeover or credentialed access.", + "description": "Security: Authentication - Looks for an unusually large spike in authentication failure events. This can be due to password spraying, user enumeration, or brute force activity and may be a precursor to account takeover or credentialed access.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json index efed4a3c9e9b18..cfa9f45c5d1ac4 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json @@ -1,7 +1,7 @@ { "id": "security_linux_v3", "title": "Security: Linux", - "description": "Anomaly detection jobs for Linux host based threat hunting and detection.", + "description": "Anomaly detection jobs for Linux host-based threat hunting and detection.", "type": "linux data", "logoFile": "logo.json", "defaultIndexPattern": "auditbeat-*,logs-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json index 2360233937c2b2..45375ad939f362 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network activity to one destination country in the network logs. This could be due to unusually large amounts of reconnaissance or enumeration traffic. Data exfiltration activity may also produce such a surge in traffic to a destination country which does not normally appear in network traffic or business work-flows. Malware instances and persistence mechanisms may communicate with command-and-control (C2) infrastructure in their country of origin, which may be an unusual destination country for the source network.", + "description": "Security: Network - Looks for an unusually large spike in network activity to one destination country in the network logs. This could be due to unusually large amounts of reconnaissance or enumeration traffic. Data exfiltration activity may also produce such a surge in traffic to a destination country which does not normally appear in network traffic or business work-flows. Malware instances and persistence mechanisms may communicate with command-and-control (C2) infrastructure in their country of origin, which may be an unusual destination country for the source network.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json index 2a3b4b01001830..45c22599f37d24 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network traffic that was denied by network ACLs or firewall rules. Such a burst of denied traffic is usually either 1) a misconfigured application or firewall or 2) suspicious or malicious activity. Unsuccessful attempts at network transit, in order to connect to command-and-control (C2), or engage in data exfiltration, may produce a burst of failed connections. This could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", + "description": "Security: Network - Looks for an unusually large spike in network traffic that was denied by network ACLs or firewall rules. Such a burst of denied traffic is usually either 1) a misconfigured application or firewall or 2) suspicious or malicious activity. Unsuccessful attempts at network transit, in order to connect to command-and-control (C2), or engage in data exfiltration, may produce a burst of failed connections. This could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json index 792d7f2513985a..a3bb734ad9bdc5 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network traffic. Such a burst of traffic, if not caused by a surge in business activity, can be due to suspicious or malicious activity. Large-scale data exfiltration may produce a burst of network traffic; this could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", + "description": "Security: Network - Looks for an unusually large spike in network traffic. Such a burst of traffic, if not caused by a surge in business activity, can be due to suspicious or malicious activity. Large-scale data exfiltration may produce a burst of network traffic; this could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json index bf39cd7ec79028..8d01d0d91e0c27 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json @@ -1,7 +1,7 @@ { "id": "security_windows_v3", "title": "Security: Windows", - "description": "Anomaly detection jobs for Windows host based threat hunting and detection.", + "description": "Anomaly detection jobs for Windows host-based threat hunting and detection.", "type": "windows data", "logoFile": "logo.json", "defaultIndexPattern": "winlogbeat-*,logs-*", diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index 93b68ea3fd9907..a5cb560d324d2c 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -131,7 +131,7 @@ export function checksFactory( ); const dfaJobsCreateTimeMap = dfaJobs.data_frame_analytics.reduce((acc, cur) => { - acc.set(cur.id, cur.create_time); + acc.set(cur.id, cur.create_time!); return acc; }, new Map()); diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/monitoring/common/http_api/cluster/index.ts similarity index 59% rename from x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts rename to x-pack/plugins/monitoring/common/http_api/cluster/index.ts index 967525de9bd6e9..af53ade67f610a 100644 --- a/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/monitoring/common/http_api/cluster/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export type { ExplainLogRateSpikesSpec } from '../../components/explain_log_rate_spikes'; -export { ExplainLogRateSpikes } from '../../components/explain_log_rate_spikes'; +export * from './post_cluster'; +export * from './post_clusters'; diff --git a/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts b/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts new file mode 100644 index 00000000000000..faa26989fec372 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { ccsRT, clusterUuidRT, timeRangeRT } from '../shared'; + +export const postClusterRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postClusterRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + codePaths: rt.array(rt.string), + }), +]); + +export type PostClusterRequestPayload = rt.TypeOf; + +export const postClusterResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts b/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts new file mode 100644 index 00000000000000..ad3214c354bc5a --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { timeRangeRT } from '../shared'; + +export const postClustersRequestPayloadRT = rt.type({ + timeRange: timeRangeRT, + codePaths: rt.array(rt.string), +}); + +export type PostClustersRequestPayload = rt.TypeOf; + +export const postClustersResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/index.ts b/x-pack/plugins/monitoring/common/http_api/setup/index.ts new file mode 100644 index 00000000000000..33cce5833c3c5c --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './post_cluster_setup_status'; +export * from './post_node_setup_status'; +export * from './post_disable_internal_collection'; diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts new file mode 100644 index 00000000000000..2c4f1293fb89ef --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { + booleanFromStringRT, + ccsRT, + clusterUuidRT, + createLiteralValueFromUndefinedRT, + timeRangeRT, +} from '../shared'; + +export const postClusterSetupStatusRequestParamsRT = rt.partial({ + clusterUuid: clusterUuidRT, +}); + +export const postClusterSetupStatusRequestQueryRT = rt.partial({ + // This flag is not intended to be used in production. It was introduced + // as a way to ensure consistent API testing - the typical data source + // for API tests are archived data, where the cluster configuration and data + // are consistent from environment to environment. However, this endpoint + // also attempts to retrieve data from the running stack products (ES and Kibana) + // which will vary from environment to environment making it difficult + // to write tests against. Therefore, this flag exists and should only be used + // in our testing environment. + skipLiveData: rt.union([booleanFromStringRT, createLiteralValueFromUndefinedRT(false)]), +}); + +export const postClusterSetupStatusRequestPayloadRT = rt.partial({ + ccs: ccsRT, + timeRange: timeRangeRT, +}); + +export type PostClusterSetupStatusRequestPayload = rt.TypeOf< + typeof postClusterSetupStatusRequestPayloadRT +>; + +export const postClusterSetupStatusResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts new file mode 100644 index 00000000000000..d44794d7e18291 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT } from '../shared'; + +export const postDisableInternalCollectionRequestParamsRT = rt.partial({ + // the cluster uuid seems to be required but never used + clusterUuid: clusterUuidRT, +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts new file mode 100644 index 00000000000000..1d51d36ae44771 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { + booleanFromStringRT, + ccsRT, + createLiteralValueFromUndefinedRT, + timeRangeRT, +} from '../shared'; + +export const postNodeSetupStatusRequestParamsRT = rt.type({ + nodeUuid: rt.string, +}); + +export const postNodeSetupStatusRequestQueryRT = rt.partial({ + // This flag is not intended to be used in production. It was introduced + // as a way to ensure consistent API testing - the typical data source + // for API tests are archived data, where the cluster configuration and data + // are consistent from environment to environment. However, this endpoint + // also attempts to retrieve data from the running stack products (ES and Kibana) + // which will vary from environment to environment making it difficult + // to write tests against. Therefore, this flag exists and should only be used + // in our testing environment. + skipLiveData: rt.union([booleanFromStringRT, createLiteralValueFromUndefinedRT(false)]), +}); + +export const postNodeSetupStatusRequestPayloadRT = rt.partial({ + ccs: ccsRT, + timeRange: timeRangeRT, +}); + +export type PostNodeSetupStatusRequestPayload = rt.TypeOf< + typeof postNodeSetupStatusRequestPayloadRT +>; + +export const postNodeSetupStatusResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts b/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts new file mode 100644 index 00000000000000..3d70e866206025 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { either } from 'fp-ts'; +import * as rt from 'io-ts'; +import { createLiteralValueFromUndefinedRT } from './literal_value'; + +describe('LiteralValueFromUndefined runtime type', () => { + it('decodes undefined to a given literal value', () => { + expect(createLiteralValueFromUndefinedRT('SOME_VALUE').decode(undefined)).toEqual( + either.right('SOME_VALUE') + ); + }); + + it('can be used to define default values when decoding', () => { + expect( + rt.union([rt.boolean, createLiteralValueFromUndefinedRT(true)]).decode(undefined) + ).toEqual(either.right(true)); + }); + + it('rejects other values', () => { + expect( + either.isLeft(createLiteralValueFromUndefinedRT('SOME_VALUE').decode('DEFINED')) + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts b/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts new file mode 100644 index 00000000000000..1801c6746feb2f --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { either } from 'fp-ts'; +import { booleanFromStringRT } from './query_string_boolean'; + +describe('BooleanFromString runtime type', () => { + it('decodes string "true" to a boolean', () => { + expect(booleanFromStringRT.decode('true')).toEqual(either.right(true)); + }); + + it('decodes string "false" to a boolean', () => { + expect(booleanFromStringRT.decode('false')).toEqual(either.right(false)); + }); + + it('rejects other strings', () => { + expect(either.isLeft(booleanFromStringRT.decode('maybe'))).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/monitoring/server/debug_logger.ts b/x-pack/plugins/monitoring/server/debug_logger.ts index 0add1f12f03041..cce00f834cbb20 100644 --- a/x-pack/plugins/monitoring/server/debug_logger.ts +++ b/x-pack/plugins/monitoring/server/debug_logger.ts @@ -4,18 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { RouteMethod } from '@kbn/core/server'; import fs from 'fs'; import { MonitoringConfig } from './config'; -import { RouteDependencies } from './types'; +import { LegacyRequest, MonitoringCore, MonitoringRouteConfig, RouteDependencies } from './types'; export function decorateDebugServer( - _server: any, + server: MonitoringCore, config: MonitoringConfig, logger: RouteDependencies['logger'] -) { +): MonitoringCore { // bail if the proper config value is not set (extra protection) if (!config.ui.debug_mode) { - return _server; + return server; } // create a debug logger that will either write to file (if debug_log_path exists) or log out via logger @@ -23,14 +24,16 @@ export function decorateDebugServer( return { // maintain the rest of _server untouched - ..._server, + ...server, // TODO: replace any - route: (options: any) => { + route: ( + options: MonitoringRouteConfig + ) => { const apiPath = options.path; - return _server.route({ + return server.route({ ...options, // TODO: replace any - handler: async (req: any) => { + handler: async (req: LegacyRequest): Promise => { const { elasticsearch: cached } = req.server.plugins; const apiRequestHeaders = req.headers; req.server.plugins.elasticsearch = { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts index 80d17a8ad06275..f93c3f8ad75902 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts @@ -6,13 +6,18 @@ */ import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../common/constants'; +import { TimeRange } from '../../../common/http_api/shared'; import { ElasticsearchResponse } from '../../../common/types/es'; -import { LegacyRequest, Cluster } from '../../types'; -import { getNewIndexPatterns } from './get_index_patterns'; import { Globals } from '../../static_globals'; +import { Cluster, LegacyRequest } from '../../types'; +import { getNewIndexPatterns } from './get_index_patterns'; + +export interface FindSupportClusterRequestPayload { + timeRange: TimeRange; +} async function findSupportedBasicLicenseCluster( - req: LegacyRequest, + req: LegacyRequest, clusters: Cluster[], ccs: string, kibanaUuid: string, @@ -53,7 +58,7 @@ async function findSupportedBasicLicenseCluster( }, }, { term: { 'kibana_stats.kibana.uuid': kibanaUuid } }, - { range: { timestamp: { gte, lte, format: 'strict_date_optional_time' } } }, + { range: { timestamp: { gte, lte, format: 'epoch_millis' } } }, ], }, }, @@ -86,7 +91,10 @@ async function findSupportedBasicLicenseCluster( * Non-Basic license clusters and any cluster in a single-cluster environment * are also flagged as supported in this method. */ -export function flagSupportedClusters(req: LegacyRequest, ccs: string) { +export function flagSupportedClusters( + req: LegacyRequest, + ccs: string +) { const serverLog = (message: string) => req.getLogger('supported-clusters').debug(message); const flagAllSupported = (clusters: Cluster[]) => { clusters.forEach((cluster) => { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts index 7d470857dfe5ae..2ebf4fe6b480e3 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { LegacyServer } from '../../types'; import { prefixIndexPatternWithCcs } from '../../../common/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH, @@ -20,14 +19,13 @@ import { INDEX_PATTERN_ENTERPRISE_SEARCH, CCS_REMOTE_PATTERN, } from '../../../common/constants'; -import { MonitoringConfig } from '../..'; +import { MonitoringConfig } from '../../config'; export function getIndexPatterns( - server: LegacyServer, + config: MonitoringConfig, additionalPatterns: Record = {}, ccs: string = CCS_REMOTE_PATTERN ) { - const config = server.config; const esIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_ELASTICSEARCH, ccs); const kbnIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_KIBANA, ccs); const lsIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_LOGSTASH, ccs); diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts index 3bd9f6d2265dc1..a5ee876012c1df 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts @@ -19,7 +19,7 @@ import { LegacyRequest } from '../../types'; */ // TODO: replace LegacyRequest with current request object + plugin retrieval -export async function verifyMonitoringAuth(req: LegacyRequest) { +export async function verifyMonitoringAuth(req: LegacyRequest) { const xpackInfo = get(req.server.plugins.monitoring, 'info'); if (xpackInfo) { @@ -42,7 +42,7 @@ export async function verifyMonitoringAuth(req: LegacyRequest) { */ // TODO: replace LegacyRequest with current request object + plugin retrieval -async function verifyHasPrivileges(req: LegacyRequest) { +async function verifyHasPrivileges(req: LegacyRequest): Promise { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); let response; diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts similarity index 79% rename from x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js rename to x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts index 214e8d5907443d..ed92948be8e3b5 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts @@ -5,49 +5,50 @@ * 2.0. */ -import { getCollectionStatus } from '.'; +import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; +import { infraPluginMock } from '@kbn/infra-plugin/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; +import { configSchema, createConfig } from '../../../config'; +import { monitoringPluginMock } from '../../../mocks'; +import { LegacyRequest } from '../../../types'; import { getIndexPatterns } from '../../cluster/get_index_patterns'; +import { getCollectionStatus } from './get_collection_status'; const liveClusterUuid = 'a12'; const mockReq = ( - searchResult = {}, - securityEnabled = true, - userHasPermissions = true, - securityErrorMessage = null -) => { + searchResult: object = {}, + securityEnabled: boolean = true, + userHasPermissions: boolean = true, + securityErrorMessage: string | null = null +): LegacyRequest => { + const usageCollectionSetup = usageCollectionPluginMock.createSetupContract(); + const licenseService = monitoringPluginMock.createLicenseServiceMock(); + licenseService.getSecurityFeature.mockReturnValue({ + isAvailable: securityEnabled, + isEnabled: securityEnabled, + }); + const logger = loggerMock.create(); + return { server: { instanceUuid: 'kibana-1234', newPlatform: { setup: { plugins: { - usageCollection: { - getCollectorByType: () => ({ - isReady: () => false, - }), - }, + usageCollection: usageCollectionSetup, + features: featuresPluginMock.createSetup(), + infra: infraPluginMock.createSetupContract(), }, }, }, - config: { ui: { ccs: { enabled: false } } }, - usage: { - collectorSet: { - getCollectorByType: () => ({ - isReady: () => false, - }), - }, - }, + config: createConfig(configSchema.validate({ ui: { ccs: { enabled: false } } })), + log: logger, + route: jest.fn(), plugins: { monitoring: { info: { - getLicenseService: () => ({ - getSecurityFeature: () => { - return { - isAvailable: securityEnabled, - isEnabled: securityEnabled, - }; - }, - }), + getLicenseService: () => licenseService, }, }, elasticsearch: { @@ -86,6 +87,17 @@ const mockReq = ( }, }, }, + logger, + getLogger: () => logger, + params: {}, + payload: {}, + query: {}, + headers: {}, + getKibanaStatsCollector: () => null, + getUiSettingsService: () => null, + getActionTypeRegistry: () => null, + getRulesClient: () => null, + getActionsClient: () => null, }; }; @@ -124,7 +136,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(1); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(0); @@ -173,7 +185,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(1); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(1); @@ -229,7 +241,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(2); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(1); @@ -251,7 +263,11 @@ describe('getCollectionStatus', () => { it('should detect products based on other indices', async () => { const req = mockReq({ hits: { total: { value: 1 } } }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); expect(result.elasticsearch.detected.doesExist).toBe(true); @@ -261,13 +277,21 @@ describe('getCollectionStatus', () => { it('should work properly when security is disabled', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, false); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); it('should work properly with an unknown security message', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, true, true, 'foobar'); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result._meta.hasPermissions).toBe(false); }); @@ -278,7 +302,11 @@ describe('getCollectionStatus', () => { true, 'no handler found for uri [/_security/user/_has_privileges] and method [POST]' ); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); @@ -289,13 +317,21 @@ describe('getCollectionStatus', () => { true, 'Invalid index name [_security]' ); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); it('should not work if the user does not have the necessary permissions', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, true, false); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result._meta.hasPermissions).toBe(false); }); }); diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts index b06b74fd255f45..568b8bbaef567b 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts @@ -5,17 +5,18 @@ * 2.0. */ -import { get, uniq } from 'lodash'; import { CollectorFetchContext, UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import { get, uniq } from 'lodash'; import { - METRICBEAT_INDEX_NAME_UNIQUE_TOKEN, - ELASTICSEARCH_SYSTEM_ID, APM_SYSTEM_ID, - KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID, - LOGSTASH_SYSTEM_ID, + ELASTICSEARCH_SYSTEM_ID, KIBANA_STATS_TYPE_MONITORING, + KIBANA_SYSTEM_ID, + LOGSTASH_SYSTEM_ID, + METRICBEAT_INDEX_NAME_UNIQUE_TOKEN, } from '../../../../common/constants'; +import { TimeRange } from '../../../../common/http_api/shared'; import { LegacyRequest } from '../../../types'; import { getLivesNodes } from '../../elasticsearch/nodes/get_nodes/get_live_nodes'; @@ -31,7 +32,7 @@ interface Bucket { const NUMBER_OF_SECONDS_AGO_TO_LOOK = 30; const getRecentMonitoringDocuments = async ( - req: LegacyRequest, + req: LegacyRequest, indexPatterns: Record, clusterUuid?: string, nodeUuid?: string, @@ -300,7 +301,7 @@ function isBeatFromAPM(bucket: Bucket) { return get(beatType, 'buckets[0].key') === 'apm-server'; } -async function hasNecessaryPermissions(req: LegacyRequest) { +async function hasNecessaryPermissions(req: LegacyRequest) { const licenseService = await req.server.plugins.monitoring.info.getLicenseService(); const securityFeature = licenseService.getSecurityFeature(); if (!securityFeature.isAvailable || !securityFeature.isEnabled) { @@ -366,7 +367,7 @@ async function getLiveKibanaInstance(usageCollection?: UsageCollectionSetup) { ); } -async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { +async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { const params = { path: '/_cluster/state/cluster_uuid', method: 'GET', @@ -377,7 +378,9 @@ async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { return clusterUuid; } -async function getLiveElasticsearchCollectionEnabled(req: LegacyRequest) { +async function getLiveElasticsearchCollectionEnabled( + req: LegacyRequest +) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); const response = await callWithRequest(req, 'transport.request', { method: 'GET', @@ -425,7 +428,7 @@ async function getLiveElasticsearchCollectionEnabled(req: LegacyRequest) { * @param {*} skipLiveData Optional and will not make any live api calls if set to true */ export const getCollectionStatus = async ( - req: LegacyRequest, + req: LegacyRequest, indexPatterns: Record, clusterUuid?: string, nodeUuid?: string, diff --git a/x-pack/plugins/monitoring/server/mocks.ts b/x-pack/plugins/monitoring/server/mocks.ts new file mode 100644 index 00000000000000..5adeae22acfc0f --- /dev/null +++ b/x-pack/plugins/monitoring/server/mocks.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ILicense } from '@kbn/licensing-plugin/server'; +import { Subject } from 'rxjs'; +import { MonitoringLicenseService } from './types'; + +const createLicenseServiceMock = (): jest.Mocked => ({ + refresh: jest.fn(), + license$: new Subject(), + getMessage: jest.fn(), + getWatcherFeature: jest.fn(), + getMonitoringFeature: jest.fn(), + getSecurityFeature: jest.fn(), + stop: jest.fn(), +}); + +// this might be incomplete and is added to as needed +export const monitoringPluginMock = { + createLicenseServiceMock, +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts index 91882151375652..b773e25b811528 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts @@ -8,15 +8,15 @@ // @ts-ignore import { ActionResult } from '@kbn/actions-plugin/common'; import { RuleTypeParams, SanitizedRule } from '@kbn/alerting-plugin/common'; -import { handleError } from '../../../../lib/errors'; -import { AlertsFactory } from '../../../../alerts'; -import { LegacyServer, RouteDependencies } from '../../../../types'; import { ALERT_ACTION_TYPE_LOG } from '../../../../../common/constants'; +import { AlertsFactory } from '../../../../alerts'; import { disableWatcherClusterAlerts } from '../../../../lib/alerts/disable_watcher_cluster_alerts'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; const DEFAULT_SERVER_LOG_NAME = 'Monitoring: Write to Kibana log'; -export function enableAlertsRoute(server: LegacyServer, npRoute: RouteDependencies) { +export function enableAlertsRoute(server: MonitoringCore, npRoute: RouteDependencies) { npRoute.router.post( { path: '/api/monitoring/v1/alerts/enable', diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts index 11782c73d9b55a..c2511e1d24c0a5 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts @@ -5,5 +5,11 @@ * 2.0. */ -export { enableAlertsRoute } from './enable'; -export { alertStatusRoute } from './status'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; +import { enableAlertsRoute } from './enable'; +import { alertStatusRoute } from './status'; + +export function registerV1AlertRoutes(server: MonitoringCore, npRoute: RouteDependencies) { + alertStatusRoute(npRoute); + enableAlertsRoute(server, npRoute); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts index a145d929216349..a9efc14c8c4584 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts @@ -6,13 +6,12 @@ */ import { schema } from '@kbn/config-schema'; -// @ts-ignore +import { CommonAlertFilter } from '../../../../../common/types/alerts'; +import { fetchStatus } from '../../../../lib/alerts/fetch_status'; import { handleError } from '../../../../lib/errors'; import { RouteDependencies } from '../../../../types'; -import { fetchStatus } from '../../../../lib/alerts/fetch_status'; -import { CommonAlertFilter } from '../../../../../common/types/alerts'; -export function alertStatusRoute(server: any, npRoute: RouteDependencies) { +export function alertStatusRoute(npRoute: RouteDependencies) { npRoute.router.post( { path: '/api/monitoring/v1/alert/{clusterUuid}/status', diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts index 0fb4dd78c9be62..97d9a2f9789d70 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts @@ -5,6 +5,13 @@ * 2.0. */ -export { apmInstanceRoute } from './instance'; -export { apmInstancesRoute } from './instances'; -export { apmOverviewRoute } from './overview'; +import { MonitoringCore } from '../../../../types'; +import { apmInstanceRoute } from './instance'; +import { apmInstancesRoute } from './instances'; +import { apmOverviewRoute } from './overview'; + +export function registerV1ApmRoutes(server: MonitoringCore) { + apmInstanceRoute(server); + apmInstancesRoute(server); + apmOverviewRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts index 57423052760bfc..935ca35c3a384c 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts @@ -5,6 +5,13 @@ * 2.0. */ -export { beatsOverviewRoute } from './overview'; -export { beatsListingRoute } from './beats'; -export { beatsDetailRoute } from './beat_detail'; +import { MonitoringCore } from '../../../../types'; +import { beatsListingRoute } from './beats'; +import { beatsDetailRoute } from './beat_detail'; +import { beatsOverviewRoute } from './overview'; + +export function registerV1BeatsRoutes(server: MonitoringCore) { + beatsDetailRoute(server); + beatsListingRoute(server); + beatsOverviewRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts index 450872049a3deb..2db7481882b89e 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts @@ -7,18 +7,19 @@ import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { LegacyRequest, MonitoringCore } from '../../../../types'; /* * API for checking read privilege on Monitoring Data * Used for the "Access Denied" page as something to auto-retry with. */ -// TODO: Replace this LegacyServer call with the "new platform" core Kibana route method -export function checkAccessRoute(server: LegacyServer) { +// TODO: Replace this legacy route registration with the "new platform" core Kibana route method +export function checkAccessRoute(server: MonitoringCore) { server.route({ - method: 'GET', + method: 'get', path: '/api/monitoring/v1/check_access', + validate: {}, handler: async (req: LegacyRequest) => { const response: { has_access?: boolean } = {}; try { diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts index 0fb8228f82442c..5209ec8b92e9a2 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts @@ -5,4 +5,9 @@ * 2.0. */ -export { checkAccessRoute } from './check_access'; +import { MonitoringCore } from '../../../../types'; +import { checkAccessRoute } from './check_access'; + +export function registerV1CheckAccessRoutes(server: MonitoringCore) { + checkAccessRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts index 30749f2e95c9fb..6bd0a19d79c5f6 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts @@ -5,39 +5,36 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { + postClusterRequestParamsRT, + postClusterRequestPayloadRT, + postClusterResponsePayloadRT, +} from '../../../../../common/http_api/cluster'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { getClustersFromRequest } from '../../../../lib/cluster/get_clusters_from_request'; -// @ts-ignore -import { handleError } from '../../../../lib/errors'; import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore } from '../../../../types'; -export function clusterRoute(server: LegacyServer) { +export function clusterRoute(server: MonitoringCore) { /* * Cluster Overview */ + + const validateParams = createValidationFunction(postClusterRequestParamsRT); + const validateBody = createValidationFunction(postClusterRequestPayloadRT); + server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - codePaths: schema.arrayOf(schema.string()), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, - handler: async (req: LegacyRequest) => { + handler: async (req) => { const config = server.config; - const indexPatterns = getIndexPatterns(server, { + const indexPatterns = getIndexPatterns(config, { filebeatIndexPattern: config.ui.logs.index, }); const options = { @@ -47,13 +44,12 @@ export function clusterRoute(server: LegacyServer) { codePaths: req.payload.codePaths, }; - let clusters = []; try { - clusters = await getClustersFromRequest(req, indexPatterns, options); + const clusters = await getClustersFromRequest(req, indexPatterns, options); + return postClusterResponsePayloadRT.encode(clusters); } catch (err) { throw handleError(err, req); } - return clusters; }, }); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts index 81acd0e53f319f..9591dda205487c 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts @@ -5,36 +5,33 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { + postClustersRequestPayloadRT, + postClustersResponsePayloadRT, +} from '../../../../../common/http_api/cluster'; import { getClustersFromRequest } from '../../../../lib/cluster/get_clusters_from_request'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { MonitoringCore } from '../../../../types'; -export function clustersRoute(server: LegacyServer) { +export function clustersRoute(server: MonitoringCore) { /* * Monitoring Home * Route Init (for checking license and compatibility for multi-cluster monitoring */ + const validateBody = createValidationFunction(postClustersRequestPayloadRT); + // TODO switch from the LegacyServer route() method to the "new platform" route methods server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters', - config: { - validate: { - body: schema.object({ - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - codePaths: schema.arrayOf(schema.string()), - }), - }, + validate: { + body: validateBody, }, - handler: async (req: LegacyRequest) => { - let clusters = []; + handler: async (req) => { const config = server.config; // NOTE using try/catch because checkMonitoringAuth is expected to throw @@ -42,17 +39,16 @@ export function clustersRoute(server: LegacyServer) { // the monitoring data. `try/catch` makes it a little more explicit. try { await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, { + const indexPatterns = getIndexPatterns(config, { filebeatIndexPattern: config.ui.logs.index, }); - clusters = await getClustersFromRequest(req, indexPatterns, { - codePaths: req.payload.codePaths as string[], // TODO remove this cast when we can properly type req by using the right route handler + const clusters = await getClustersFromRequest(req, indexPatterns, { + codePaths: req.payload.codePaths, }); + return postClustersResponsePayloadRT.encode(clusters); } catch (err) { throw handleError(err, req); } - - return clusters; }, }); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts index 769f315480d9cc..9534398db52c1b 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts @@ -5,5 +5,11 @@ * 2.0. */ -export { clusterRoute } from './cluster'; -export { clustersRoute } from './clusters'; +import { clusterRoute } from './cluster'; +import { clustersRoute } from './clusters'; +import { MonitoringCore } from '../../../../types'; + +export function registerV1ClusterRoutes(server: MonitoringCore) { + clusterRoute(server); + clustersRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts index b2d432a5e35b55..e706dc61c0a416 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts @@ -5,11 +5,23 @@ * 2.0. */ -export { esIndexRoute } from './index_detail'; -export { esIndicesRoute } from './indices'; -export { esNodeRoute } from './node_detail'; -export { esNodesRoute } from './nodes'; -export { esOverviewRoute } from './overview'; -export { mlJobRoute } from './ml_jobs'; -export { ccrRoute } from './ccr'; -export { ccrShardRoute } from './ccr_shard'; +import { MonitoringCore } from '../../../../types'; +import { ccrRoute } from './ccr'; +import { ccrShardRoute } from './ccr_shard'; +import { esIndexRoute } from './index_detail'; +import { esIndicesRoute } from './indices'; +import { mlJobRoute } from './ml_jobs'; +import { esNodesRoute } from './nodes'; +import { esNodeRoute } from './node_detail'; +import { esOverviewRoute } from './overview'; + +export function registerV1ElasticsearchRoutes(server: MonitoringCore) { + esIndexRoute(server); + esIndicesRoute(server); + esNodeRoute(server); + esNodesRoute(server); + esOverviewRoute(server); + mlJobRoute(server); + ccrRoute(server); + ccrShardRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts index 11e0eec3f08f0b..f8742144b28f8e 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts @@ -19,7 +19,7 @@ import { } from '../../../../../../common/http_api/elasticsearch_settings'; import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; import { handleError } from '../../../../../lib/errors'; -import { LegacyServer, RouteDependencies } from '../../../../../types'; +import { MonitoringCore, RouteDependencies } from '../../../../../types'; const queryBody = { size: 0, @@ -72,7 +72,7 @@ const checkLatestMonitoringIsLegacy = async (context: RequestHandlerContext, ind return counts; }; -export function internalMonitoringCheckRoute(server: LegacyServer, npRoute: RouteDependencies) { +export function internalMonitoringCheckRoute(server: MonitoringCore, npRoute: RouteDependencies) { const validateBody = createValidationFunction( postElasticsearchSettingsInternalMonitoringRequestPayloadRT ); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts index 61bb1ba804a5ac..dfc68068bf80d4 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts @@ -5,8 +5,20 @@ * 2.0. */ -export { clusterSettingsCheckRoute } from './check/cluster'; -export { internalMonitoringCheckRoute } from './check/internal_monitoring'; -export { nodesSettingsCheckRoute } from './check/nodes'; -export { setCollectionEnabledRoute } from './set/collection_enabled'; -export { setCollectionIntervalRoute } from './set/collection_interval'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; +import { clusterSettingsCheckRoute } from './check/cluster'; +import { internalMonitoringCheckRoute } from './check/internal_monitoring'; +import { nodesSettingsCheckRoute } from './check/nodes'; +import { setCollectionEnabledRoute } from './set/collection_enabled'; +import { setCollectionIntervalRoute } from './set/collection_interval'; + +export function registerV1ElasticsearchSettingsRoutes( + server: MonitoringCore, + npRoute: RouteDependencies +) { + clusterSettingsCheckRoute(server); + internalMonitoringCheckRoute(server, npRoute); + nodesSettingsCheckRoute(server); + setCollectionEnabledRoute(server); + setCollectionIntervalRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts new file mode 100644 index 00000000000000..e0f5e55c6c128a --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { registerV1AlertRoutes } from './alerts'; +export { registerV1ApmRoutes } from './apm'; +export { registerV1BeatsRoutes } from './beats'; +export { registerV1CheckAccessRoutes } from './check_access'; +export { registerV1ClusterRoutes } from './cluster'; +export { registerV1ElasticsearchRoutes } from './elasticsearch'; +export { registerV1ElasticsearchSettingsRoutes } from './elasticsearch_settings'; +export { registerV1LogstashRoutes } from './logstash'; +export { registerV1SetupRoutes } from './setup'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts index b267c17fc33465..a4975726cf0a1e 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts @@ -5,10 +5,21 @@ * 2.0. */ -export { logstashNodesRoute } from './nodes'; -export { logstashNodeRoute } from './node'; -export { logstashOverviewRoute } from './overview'; -export { logstashPipelineRoute } from './pipeline'; -export { logstashNodePipelinesRoute } from './pipelines/node_pipelines'; -export { logstashClusterPipelinesRoute } from './pipelines/cluster_pipelines'; -export { logstashClusterPipelineIdsRoute } from './pipelines/cluster_pipeline_ids'; +import { MonitoringCore } from '../../../../types'; +import { logstashNodeRoute } from './node'; +import { logstashNodesRoute } from './nodes'; +import { logstashOverviewRoute } from './overview'; +import { logstashPipelineRoute } from './pipeline'; +import { logstashClusterPipelinesRoute } from './pipelines/cluster_pipelines'; +import { logstashClusterPipelineIdsRoute } from './pipelines/cluster_pipeline_ids'; +import { logstashNodePipelinesRoute } from './pipelines/node_pipelines'; + +export function registerV1LogstashRoutes(server: MonitoringCore) { + logstashClusterPipelineIdsRoute(server); + logstashClusterPipelinesRoute(server); + logstashNodePipelinesRoute(server); + logstashNodeRoute(server); + logstashNodesRoute(server); + logstashOverviewRoute(server); + logstashPipelineRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js deleted file mode 100644 index bc8b722d22214f..00000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; -import { getCollectionStatus } from '../../../../lib/setup/collection'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; - -export function clusterSetupStatusRoute(server) { - /* - * Monitoring Home - * Route Init (for checking license and compatibility for multi-cluster monitoring - */ - server.route({ - method: 'POST', - path: '/api/monitoring/v1/setup/collection/cluster/{clusterUuid?}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.maybe(schema.string()), - }), - query: schema.object({ - // This flag is not intended to be used in production. It was introduced - // as a way to ensure consistent API testing - the typical data source - // for API tests are archived data, where the cluster configuration and data - // are consistent from environment to environment. However, this endpoint - // also attempts to retrieve data from the running stack products (ES and Kibana) - // which will vary from environment to environment making it difficult - // to write tests against. Therefore, this flag exists and should only be used - // in our testing environment. - skipLiveData: schema.boolean({ defaultValue: false }), - }), - body: schema.nullable( - schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string({ defaultValue: '' }), - max: schema.string({ defaultValue: '' }), - }), - }) - ), - }, - }, - handler: async (req) => { - let status = null; - - // NOTE using try/catch because checkMonitoringAuth is expected to throw - // an error when current logged-in user doesn't have permission to read - // the monitoring data. `try/catch` makes it a little more explicit. - try { - await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); - status = await getCollectionStatus( - req, - indexPatterns, - req.params.clusterUuid, - null, - req.query.skipLiveData - ); - } catch (err) { - throw handleError(err, req); - } - - return status; - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts new file mode 100644 index 00000000000000..370947df46b42f --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + postClusterSetupStatusRequestParamsRT, + postClusterSetupStatusRequestPayloadRT, + postClusterSetupStatusRequestQueryRT, + postClusterSetupStatusResponsePayloadRT, +} from '../../../../../common/http_api/setup'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; +import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; +import { handleError } from '../../../../lib/errors'; +import { getCollectionStatus } from '../../../../lib/setup/collection'; +import { MonitoringCore } from '../../../../types'; + +export function clusterSetupStatusRoute(server: MonitoringCore) { + /* + * Monitoring Home + * Route Init (for checking license and compatibility for multi-cluster monitoring + */ + + const validateParams = createValidationFunction(postClusterSetupStatusRequestParamsRT); + const validateQuery = createValidationFunction(postClusterSetupStatusRequestQueryRT); + const validateBody = createValidationFunction(postClusterSetupStatusRequestPayloadRT); + + server.route({ + method: 'post', + path: '/api/monitoring/v1/setup/collection/cluster/{clusterUuid?}', + validate: { + params: validateParams, + query: validateQuery, + body: validateBody, + }, + handler: async (req) => { + const clusterUuid = req.params.clusterUuid; + const skipLiveData = req.query.skipLiveData; + + // NOTE using try/catch because checkMonitoringAuth is expected to throw + // an error when current logged-in user doesn't have permission to read + // the monitoring data. `try/catch` makes it a little more explicit. + try { + await verifyMonitoringAuth(req); + const indexPatterns = getIndexPatterns(server.config, {}, req.payload.ccs); + const status = await getCollectionStatus( + req, + indexPatterns, + clusterUuid, + undefined, + skipLiveData + ); + return postClusterSetupStatusResponsePayloadRT.encode(status); + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts similarity index 74% rename from x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js rename to x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts index 9590d91c357ee0..cdecf346bae9db 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts @@ -5,21 +5,19 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { postDisableInternalCollectionRequestParamsRT } from '../../../../../common/http_api/setup'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; import { setCollectionDisabled } from '../../../../lib/elasticsearch_settings/set/collection_disabled'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore } from '../../../../types'; -export function disableElasticsearchInternalCollectionRoute(server) { +export function disableElasticsearchInternalCollectionRoute(server: MonitoringCore) { server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/setup/collection/{clusterUuid}/disable_internal_collection', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - }, + validate: { + params: createValidationFunction(postDisableInternalCollectionRequestParamsRT), }, handler: async (req) => { // NOTE using try/catch because checkMonitoringAuth is expected to throw diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts new file mode 100644 index 00000000000000..6a8ecac8597a84 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MonitoringCore } from '../../../../types'; +import { clusterSetupStatusRoute } from './cluster_setup_status'; +import { disableElasticsearchInternalCollectionRoute } from './disable_elasticsearch_internal_collection'; +import { nodeSetupStatusRoute } from './node_setup_status'; + +export function registerV1SetupRoutes(server: MonitoringCore) { + clusterSetupStatusRoute(server); + disableElasticsearchInternalCollectionRoute(server); + nodeSetupStatusRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js deleted file mode 100644 index 1f93e92843ea81..00000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; -import { getCollectionStatus } from '../../../../lib/setup/collection'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; - -export function nodeSetupStatusRoute(server) { - /* - * Monitoring Home - * Route Init (for checking license and compatibility for multi-cluster monitoring - */ - server.route({ - method: 'POST', - path: '/api/monitoring/v1/setup/collection/node/{nodeUuid}', - config: { - validate: { - params: schema.object({ - nodeUuid: schema.string(), - }), - query: schema.object({ - // This flag is not intended to be used in production. It was introduced - // as a way to ensure consistent API testing - the typical data source - // for API tests are archived data, where the cluster configuration and data - // are consistent from environment to environment. However, this endpoint - // also attempts to retrieve data from the running stack products (ES and Kibana) - // which will vary from environment to environment making it difficult - // to write tests against. Therefore, this flag exists and should only be used - // in our testing environment. - skipLiveData: schema.boolean({ defaultValue: false }), - }), - body: schema.nullable( - schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.maybe( - schema.object({ - min: schema.string(), - max: schema.string(), - }) - ), - }) - ), - }, - }, - handler: async (req) => { - let status = null; - - // NOTE using try/catch because checkMonitoringAuth is expected to throw - // an error when current logged-in user doesn't have permission to read - // the monitoring data. `try/catch` makes it a little more explicit. - try { - await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); - status = await getCollectionStatus( - req, - indexPatterns, - null, - req.params.nodeUuid, - req.query.skipLiveData - ); - } catch (err) { - throw handleError(err, req); - } - - return status; - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts new file mode 100644 index 00000000000000..327b741a0e64ab --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + postNodeSetupStatusRequestParamsRT, + postNodeSetupStatusRequestPayloadRT, + postNodeSetupStatusRequestQueryRT, + postNodeSetupStatusResponsePayloadRT, +} from '../../../../../common/http_api/setup'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; +import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; +import { handleError } from '../../../../lib/errors'; +import { getCollectionStatus } from '../../../../lib/setup/collection'; +import { MonitoringCore } from '../../../../types'; + +export function nodeSetupStatusRoute(server: MonitoringCore) { + /* + * Monitoring Home + * Route Init (for checking license and compatibility for multi-cluster monitoring + */ + + const validateParams = createValidationFunction(postNodeSetupStatusRequestParamsRT); + const validateQuery = createValidationFunction(postNodeSetupStatusRequestQueryRT); + const validateBody = createValidationFunction(postNodeSetupStatusRequestPayloadRT); + + server.route({ + method: 'post', + path: '/api/monitoring/v1/setup/collection/node/{nodeUuid}', + validate: { + params: validateParams, + query: validateQuery, + body: validateBody, + }, + handler: async (req) => { + const nodeUuid = req.params.nodeUuid; + const skipLiveData = req.query.skipLiveData; + const ccs = req.payload.ccs; + + // NOTE using try/catch because checkMonitoringAuth is expected to throw + // an error when current logged-in user doesn't have permission to read + // the monitoring data. `try/catch` makes it a little more explicit. + try { + await verifyMonitoringAuth(req); + const indexPatterns = getIndexPatterns(server.config, {}, ccs); + const status = await getCollectionStatus( + req, + indexPatterns, + undefined, + nodeUuid, + skipLiveData + ); + + return postNodeSetupStatusResponsePayloadRT.encode(status); + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/plugins/monitoring/server/routes/api/v1/ui.js deleted file mode 100644 index 618d12afedef7f..00000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// all routes for the app -export { checkAccessRoute } from './check_access'; -export * from './alerts'; -export { beatsDetailRoute, beatsListingRoute, beatsOverviewRoute } from './beats'; -export { clusterRoute, clustersRoute } from './cluster'; -export { - esIndexRoute, - esIndicesRoute, - esNodeRoute, - esNodesRoute, - esOverviewRoute, - mlJobRoute, - ccrRoute, - ccrShardRoute, -} from './elasticsearch'; -export { - internalMonitoringCheckRoute, - clusterSettingsCheckRoute, - nodesSettingsCheckRoute, - setCollectionEnabledRoute, - setCollectionIntervalRoute, -} from './elasticsearch_settings'; -export { kibanaInstanceRoute, kibanaInstancesRoute, kibanaOverviewRoute } from './kibana'; -export { apmInstanceRoute, apmInstancesRoute, apmOverviewRoute } from './apm'; -export { - logstashClusterPipelinesRoute, - logstashNodePipelinesRoute, - logstashNodeRoute, - logstashNodesRoute, - logstashOverviewRoute, - logstashPipelineRoute, - logstashClusterPipelineIdsRoute, -} from './logstash'; -export { entSearchOverviewRoute } from './enterprise_search'; -export * from './setup'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/ui.ts b/x-pack/plugins/monitoring/server/routes/api/v1/ui.ts new file mode 100644 index 00000000000000..7aaa6591e868e6 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/ui.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// these are the remaining routes not yet converted to TypeScript +// all others are registered through index.ts + +// @ts-expect-error +export { kibanaInstanceRoute, kibanaInstancesRoute, kibanaOverviewRoute } from './kibana'; +// @ts-expect-error +export { entSearchOverviewRoute } from './enterprise_search'; diff --git a/x-pack/plugins/monitoring/server/routes/index.ts b/x-pack/plugins/monitoring/server/routes/index.ts index 05a8de96b4c07d..f38612d5a42daf 100644 --- a/x-pack/plugins/monitoring/server/routes/index.ts +++ b/x-pack/plugins/monitoring/server/routes/index.ts @@ -8,22 +8,43 @@ import { MonitoringConfig } from '../config'; import { decorateDebugServer } from '../debug_logger'; -import { RouteDependencies } from '../types'; -// @ts-ignore -import * as uiRoutes from './api/v1/ui'; // namespace import +import { MonitoringCore, RouteDependencies } from '../types'; +import { + registerV1AlertRoutes, + registerV1ApmRoutes, + registerV1BeatsRoutes, + registerV1CheckAccessRoutes, + registerV1ClusterRoutes, + registerV1ElasticsearchRoutes, + registerV1ElasticsearchSettingsRoutes, + registerV1LogstashRoutes, + registerV1SetupRoutes, +} from './api/v1'; +import * as uiRoutes from './api/v1/ui'; export function requireUIRoutes( - _server: any, + server: MonitoringCore, config: MonitoringConfig, npRoute: RouteDependencies ) { const routes = Object.keys(uiRoutes); - const server = config.ui.debug_mode - ? decorateDebugServer(_server, config, npRoute.logger) - : _server; + const decoratedServer = config.ui.debug_mode + ? decorateDebugServer(server, config, npRoute.logger) + : server; routes.forEach((route) => { + // @ts-expect-error const registerRoute = uiRoutes[route]; // computed reference to module objects imported via namespace registerRoute(server, npRoute); }); + + registerV1AlertRoutes(decoratedServer, npRoute); + registerV1ApmRoutes(server); + registerV1BeatsRoutes(server); + registerV1CheckAccessRoutes(server); + registerV1ClusterRoutes(server); + registerV1ElasticsearchRoutes(server); + registerV1ElasticsearchSettingsRoutes(server, npRoute); + registerV1LogstashRoutes(server); + registerV1SetupRoutes(server); } diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 86447a24fdf048..64931f58885140 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -34,7 +34,7 @@ import { LicensingPluginStart } from '@kbn/licensing-plugin/server'; import { PluginSetupContract as FeaturesPluginSetupContract } from '@kbn/features-plugin/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { CloudSetup } from '@kbn/cloud-plugin/server'; -import { RouteConfig, RouteMethod } from '@kbn/core/server'; +import { RouteConfig, RouteMethod, Headers } from '@kbn/core/server'; import { ElasticsearchModifiedSource } from '../common/types/es'; import { RulesByType } from '../common/types/alerts'; import { configSchema, MonitoringConfig } from './config'; @@ -124,6 +124,7 @@ export interface LegacyRequest { payload: Body; params: Params; query: Query; + headers: Headers; getKibanaStatsCollector: () => any; getUiSettingsService: () => any; getActionTypeRegistry: () => any; diff --git a/x-pack/plugins/observability/public/config/paths.ts b/x-pack/plugins/observability/public/config/paths.ts index 57bbc95fef40b6..7f6599ef3c4835 100644 --- a/x-pack/plugins/observability/public/config/paths.ts +++ b/x-pack/plugins/observability/public/config/paths.ts @@ -5,9 +5,14 @@ * 2.0. */ +export const ALERT_PAGE_LINK = '/app/observability/alerts'; +export const RULES_PAGE_LINK = `${ALERT_PAGE_LINK}/rules`; + export const paths = { observability: { - alerts: '/app/observability/alerts', + alerts: ALERT_PAGE_LINK, + rules: RULES_PAGE_LINK, + ruleDetails: (ruleId: string) => `${RULES_PAGE_LINK}/${encodeURI(ruleId)}`, }, management: { rules: '/app/management/insightsAndAlerting/triggersActions/rules', diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_last24h_rule_execution_log.ts b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_rule_execution_log.ts new file mode 100644 index 00000000000000..edb08f69b44f30 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_rule_execution_log.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { loadExecutionLogAggregations } from '@kbn/triggers-actions-ui-plugin/public'; +import { IExecutionLogWithErrorsResult } from '@kbn/alerting-plugin/common'; +import moment from 'moment'; +import { FetchRuleExecutionLogProps } from '../pages/rule_details/types'; +import { EXECUTION_LOG_ERROR } from '../pages/rule_details/translations'; +import { useKibana } from '../utils/kibana_react'; + +interface FetchExecutionLog { + isLoadingExecutionLog: boolean; + executionLog: IExecutionLogWithErrorsResult; + errorExecutionLog?: string; +} + +export function useFetchLast24hRuleExecutionLog({ http, ruleId }: FetchRuleExecutionLogProps) { + const { + notifications: { toasts }, + } = useKibana().services; + const [executionLog, setExecutionLog] = useState({ + isLoadingExecutionLog: true, + executionLog: { + total: 0, + data: [], + totalErrors: 0, + errors: [], + }, + errorExecutionLog: undefined, + }); + + const fetchRuleActions = useCallback(async () => { + try { + const date = new Date().toISOString(); + const response = await loadExecutionLogAggregations({ + id: ruleId, + dateStart: moment(date).subtract(24, 'h').toISOString(), + dateEnd: date, + http, + }); + setExecutionLog((oldState: FetchExecutionLog) => ({ + ...oldState, + isLoadingExecutionLog: false, + executionLog: response, + })); + } catch (error) { + toasts.addDanger({ title: error }); + setExecutionLog((oldState: FetchExecutionLog) => ({ + ...oldState, + isLoadingExecutionLog: false, + errorExecutionLog: EXECUTION_LOG_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + }, [http, ruleId, toasts]); + useEffect(() => { + fetchRuleActions(); + }, [fetchRuleActions]); + + return { ...executionLog, reloadExecutionLogs: useFetchLast24hRuleExecutionLog }; +} diff --git a/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts b/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts index ab1f769c1c4b93..a20e42cd378417 100644 --- a/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts +++ b/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts @@ -46,6 +46,12 @@ const triggersActionsUiStartMock = { get: jest.fn(), list: jest.fn(), }, + actionTypeRegistry: { + has: jest.fn((x) => true), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }, }; }, }; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx index d0957f0224b53d..5a1b88ff1a4205 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout.tsx @@ -77,8 +77,7 @@ export function AlertsFlyout({ } const ruleId = alertData.fields['kibana.alert.rule.uuid'] ?? null; - const linkToRule = ruleId && prepend ? prepend(paths.management.ruleDetails(ruleId)) : null; - + const linkToRule = ruleId && prepend ? prepend(paths.observability.ruleDetails(ruleId)) : null; const overviewListItems = [ { title: translations.alertsFlyout.statusLabel, diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx index 621a43eedfc25b..c9d2d67e11bdc0 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx @@ -170,8 +170,7 @@ function ObservabilityActions({ const casePermissions = useGetUserCasesPermissions(); const ruleId = alert.fields['kibana.alert.rule.uuid'] ?? null; - const linkToRule = ruleId ? http.basePath.prepend(paths.management.ruleDetails(ruleId)) : null; - + const linkToRule = ruleId ? http.basePath.prepend(paths.observability.ruleDetails(ruleId)) : null; const caseAttachments: CaseAttachments = useMemo(() => { return ecsData?._id ? [ diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx new file mode 100644 index 00000000000000..9000d9dbf5f99e --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { ReactWrapper, mount } from 'enzyme'; +import { Actions } from './actions'; +import { observabilityPublicPluginsStartMock } from '../../../observability_public_plugins_start.mock'; +import { kibanaStartMock } from '../../../utils/kibana_react.mock'; + +const mockUseKibanaReturnValue = kibanaStartMock.startContract(); + +jest.mock('../../../utils/kibana_react', () => ({ + __esModule: true, + useKibana: jest.fn(() => mockUseKibanaReturnValue), +})); + +jest.mock('../../../hooks/use_fetch_rule_actions', () => ({ + useFetchRuleActions: jest.fn(), +})); + +const { useFetchRuleActions } = jest.requireMock('../../../hooks/use_fetch_rule_actions'); + +describe('Actions', () => { + let wrapper: ReactWrapper; + async function setup() { + const ruleActions = [ + { + id: 1, + group: 'metrics.inventory_threshold.fired', + actionTypeId: '.server-log', + }, + { + id: 2, + group: 'metrics.inventory_threshold.fired', + actionTypeId: '.slack', + }, + ]; + const allActions = [ + { + id: 1, + name: 'Server log', + actionTypeId: '.server-log', + }, + { + id: 2, + name: 'Slack', + actionTypeId: '.slack', + }, + { + id: 3, + name: 'Email', + actionTypeId: '.email', + }, + ]; + useFetchRuleActions.mockReturnValue({ + allActions, + }); + + const actionTypeRegistryMock = + observabilityPublicPluginsStartMock.createStart().triggersActionsUi.actionTypeRegistry; + actionTypeRegistryMock.list.mockReturnValue([ + { id: '.server-log', iconClass: 'logsApp' }, + { id: '.slack', iconClass: 'logoSlack' }, + { id: '.email', iconClass: 'email' }, + { id: '.index', iconClass: 'indexOpen' }, + ]); + wrapper = mount( + + ); + } + + it("renders action connector icons for user's selected rule actions", async () => { + await setup(); + wrapper.debug(); + expect(wrapper.find('[data-euiicon-type]').length).toBe(2); + expect(wrapper.find('[data-euiicon-type="logsApp"]').length).toBe(1); + expect(wrapper.find('[data-euiicon-type="logoSlack"]').length).toBe(1); + expect(wrapper.find('[data-euiicon-type="index"]').length).toBe(0); + expect(wrapper.find('[data-euiicon-type="email"]').length).toBe(0); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx index 5a692e570281ac..d3dbe3cf4bdef6 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { EuiText, EuiSpacer, @@ -15,29 +15,23 @@ import { EuiLoadingSpinner, } from '@elastic/eui'; import { intersectionBy } from 'lodash'; +import { suspendedComponentWithProps } from '@kbn/triggers-actions-ui-plugin/public'; import { i18n } from '@kbn/i18n'; import { ActionsProps } from '../types'; import { useFetchRuleActions } from '../../../hooks/use_fetch_rule_actions'; import { useKibana } from '../../../utils/kibana_react'; -interface MapActionTypeIcon { - [key: string]: string | IconType; -} -const mapActionTypeIcon: MapActionTypeIcon = { - /* TODO: Add the rest of the application logs (SVGs ones) */ - '.server-log': 'logsApp', - '.email': 'email', - '.pagerduty': 'apps', - '.index': 'indexOpen', - '.slack': 'logoSlack', - '.webhook': 'logoWebhook', -}; -export function Actions({ ruleActions }: ActionsProps) { +export function Actions({ ruleActions, actionTypeRegistry }: ActionsProps) { const { http, notifications: { toasts }, } = useKibana().services; const { isLoadingActions, allActions, errorActions } = useFetchRuleActions({ http }); + useEffect(() => { + if (errorActions) { + toasts.addDanger({ title: errorActions }); + } + }, [errorActions, toasts]); if (ruleActions && ruleActions.length <= 0) return ( @@ -48,24 +42,32 @@ export function Actions({ ruleActions }: ActionsProps) {
); + + function getActionIconClass(actionGroupId?: string): IconType | undefined { + const actionGroup = actionTypeRegistry.list().find((group) => group.id === actionGroupId); + return typeof actionGroup?.iconClass === 'string' + ? actionGroup?.iconClass + : suspendedComponentWithProps(actionGroup?.iconClass as React.ComponentType); + } const actions = intersectionBy(allActions, ruleActions, 'actionTypeId'); if (isLoadingActions) return ; return ( - {actions.map((action) => ( - <> - + {actions.map(({ actionTypeId, name }) => ( + + - + - - {action.name} + + + {name} + - + ))} - {errorActions && toasts.addDanger({ title: errorActions })} ); } diff --git a/x-pack/plugins/observability/public/pages/rule_details/config.ts b/x-pack/plugins/observability/public/pages/rule_details/config.ts index e73849f47e7b3e..8822c68a85a0b6 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/config.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/config.ts @@ -18,6 +18,3 @@ export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean export const hasExecuteActionsCapability = (capabilities: Capabilities) => capabilities?.actions?.execute; - -export const RULES_PAGE_LINK = '/app/observability/alerts/rules'; -export const ALERT_PAGE_LINK = '/app/observability/alerts'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 9cce5bfb99c922..5cc12452e57e14 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -34,6 +34,7 @@ import { deleteRules, useLoadRuleTypes, RuleType, + NOTIFY_WHEN_OPTIONS, RuleEventLogListProps, } from '@kbn/triggers-actions-ui-plugin/public'; // TODO: use a Delete modal from triggersActionUI when it's sharable @@ -55,13 +56,10 @@ import { RULES_BREADCRUMB_TEXT } from '../rules/translations'; import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from './components'; import { useKibana } from '../../utils/kibana_react'; import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts'; +import { useFetchLast24hRuleExecutionLog } from '../../hooks/use_fetch_last24h_rule_execution_log'; import { formatInterval } from './utils'; -import { - hasExecuteActionsCapability, - hasAllPrivilege, - RULES_PAGE_LINK, - ALERT_PAGE_LINK, -} from './config'; +import { hasExecuteActionsCapability, hasAllPrivilege } from './config'; +import { paths } from '../../config/paths'; export function RuleDetailsPage() { const { @@ -70,6 +68,7 @@ export function RuleDetailsPage() { ruleTypeRegistry, getRuleStatusDropdown, getEditAlertFlyout, + actionTypeRegistry, getRuleEventLogList, }, application: { capabilities, navigateToUrl }, @@ -79,7 +78,8 @@ export function RuleDetailsPage() { const { ruleId } = useParams(); const { ObservabilityPageTemplate } = usePluginContext(); const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); - const { ruleTypes } = useLoadRuleTypes({ + const { isLoadingExecutionLog, executionLog } = useFetchLast24hRuleExecutionLog({ http, ruleId }); + const { ruleTypes, ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS, }); @@ -113,8 +113,9 @@ export function RuleDetailsPage() { useEffect(() => { if (ruleTypes.length && rule) { const matchedRuleType = ruleTypes.find((type) => type.id === rule.ruleTypeId); + setRuleType(matchedRuleType); + if (rule.consumer === ALERTS_FEATURE_ID && matchedRuleType && matchedRuleType.producer) { - setRuleType(matchedRuleType); setFeatures(matchedRuleType.producer); } else setFeatures(rule.consumer); } @@ -125,10 +126,10 @@ export function RuleDetailsPage() { text: i18n.translate('xpack.observability.breadcrumbs.alertsLinkText', { defaultMessage: 'Alerts', }), - href: http.basePath.prepend(ALERT_PAGE_LINK), + href: http.basePath.prepend(paths.observability.alerts), }, { - href: http.basePath.prepend(RULES_PAGE_LINK), + href: http.basePath.prepend(paths.observability.rules), text: RULES_BREADCRUMB_TEXT, }, { @@ -221,6 +222,9 @@ export function RuleDetailsPage() { /> ); + const getNotifyText = () => + NOTIFY_WHEN_OPTIONS.find((option) => option.value === rule?.notifyWhen)?.inputDisplay || + rule.notifyWhen; return ( - + {/* Left side of Rule Summary */} - + @@ -314,7 +318,7 @@ export function RuleDetailsPage() { - + {i18n.translate('xpack.observability.ruleDetails.lastRun', { @@ -326,11 +330,7 @@ export function RuleDetailsPage() { itemValue={moment(rule.executionStatus.lastExecutionDate).fromNow()} /> - - - - - + {i18n.translate('xpack.observability.ruleDetails.alerts', { @@ -349,8 +349,29 @@ export function RuleDetailsPage() { )}`} /> - - + + {isLoadingExecutionLog ? ( + + ) : ( + + + {i18n.translate('xpack.observability.ruleDetails.execution', { + defaultMessage: 'Executions', + })} + + + + + )} @@ -358,7 +379,7 @@ export function RuleDetailsPage() { {/* Right side of Rule Summary */} - + @@ -374,7 +395,7 @@ export function RuleDetailsPage() { )} - + @@ -384,12 +405,14 @@ export function RuleDetailsPage() { defaultMessage: 'Rule type', })} - + - + - + {i18n.translate('xpack.observability.ruleDetails.description', { defaultMessage: 'Description', @@ -400,7 +423,7 @@ export function RuleDetailsPage() { /> - + @@ -420,8 +443,6 @@ export function RuleDetailsPage() { - - @@ -434,7 +455,7 @@ export function RuleDetailsPage() { - + @@ -442,11 +463,10 @@ export function RuleDetailsPage() { defaultMessage: 'Notify', })} - - + - + {i18n.translate('xpack.observability.ruleDetails.actions', { @@ -454,7 +474,7 @@ export function RuleDetailsPage() { })} - + @@ -476,11 +496,11 @@ export function RuleDetailsPage() { { setRuleToDelete([]); - navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + navigateToUrl(http.basePath.prepend(paths.observability.rules)); }} onErrors={async () => { setRuleToDelete([]); - navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + navigateToUrl(http.basePath.prepend(paths.observability.rules)); }} onCancel={() => {}} apiDeleteCall={deleteRules} diff --git a/x-pack/plugins/observability/public/pages/rule_details/translations.ts b/x-pack/plugins/observability/public/pages/rule_details/translations.ts index f162f30906c216..bda8284c31a9ea 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/translations.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/translations.ts @@ -18,6 +18,12 @@ export const ACTIONS_LOAD_ERROR = (errorMessage: string) => values: { message: errorMessage }, }); +export const EXECUTION_LOG_ERROR = (errorMessage: string) => + i18n.translate('xpack.observability.ruleDetails.executionLogError', { + defaultMessage: 'Unable to load rule execution log. Reason: {message}', + values: { message: errorMessage }, + }); + export const TAGS_TITLE = i18n.translate('xpack.observability.ruleDetails.tagsTitle', { defaultMessage: 'Tags', }); diff --git a/x-pack/plugins/observability/public/pages/rule_details/types.ts b/x-pack/plugins/observability/public/pages/rule_details/types.ts index 9855bf2c7f184f..4b1c62f7dbb9a3 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/types.ts +++ b/x-pack/plugins/observability/public/pages/rule_details/types.ts @@ -6,7 +6,12 @@ */ import { HttpSetup } from '@kbn/core/public'; -import { Rule, RuleSummary, RuleType } from '@kbn/triggers-actions-ui-plugin/public'; +import { + Rule, + RuleSummary, + RuleType, + ActionTypeRegistryContract, +} from '@kbn/triggers-actions-ui-plugin/public'; export interface RuleDetailsPathParams { ruleId: string; @@ -35,6 +40,11 @@ export interface FetchRuleActionsProps { http: HttpSetup; } +export interface FetchRuleExecutionLogProps { + http: HttpSetup; + ruleId: string; +} + export interface FetchRuleSummary { isLoadingRuleSummary: boolean; ruleSummary?: RuleSummary; @@ -63,6 +73,7 @@ export interface ItemValueRuleSummaryProps { } export interface ActionsProps { ruleActions: any[]; + actionTypeRegistry: ActionTypeRegistryContract; } export const EVENT_LOG_LIST_TAB = 'rule_event_log_list'; diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index 15cb44412d8800..96418758df0a5a 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -9,10 +9,11 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { RuleNameProps } from '../types'; import { useKibana } from '../../../utils/kibana_react'; +import { paths } from '../../../config/paths'; export function Name({ name, rule }: RuleNameProps) { const { http } = useKibana().services; - const detailsLink = http.basePath.prepend(`/app/observability/alerts/rules/${rule.id}`); + const detailsLink = http.basePath.prepend(paths.observability.ruleDetails(rule.id)); const link = ( diff --git a/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts index d6f8e14381bc22..b1a3d26d850d07 100644 --- a/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts @@ -101,7 +101,9 @@ describe('ALL - Add Integration', () => { findFormFieldByRowsLabelAndType('Name', 'Integration'); findFormFieldByRowsLabelAndType('Scheduled agent policies (optional)', '{downArrow} {enter}'); findAndClickButton('Add query'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) + cy.react('EuiComboBox', { + props: { placeholder: 'Search for a query to run, or write a new query below' }, + }) .click() .type('{downArrow} {enter}'); cy.contains(/^Save$/).click(); diff --git a/x-pack/plugins/osquery/cypress/screens/live_query.ts b/x-pack/plugins/osquery/cypress/screens/live_query.ts index ce29edc2c9187d..d3be652c24c2cd 100644 --- a/x-pack/plugins/osquery/cypress/screens/live_query.ts +++ b/x-pack/plugins/osquery/cypress/screens/live_query.ts @@ -14,4 +14,6 @@ export const RESULTS_TABLE = 'osqueryResultsTable'; export const RESULTS_TABLE_BUTTON = 'dataGridFullScreenButton'; export const RESULTS_TABLE_CELL_WRRAPER = 'EuiDataGridHeaderCellWrapper'; export const getSavedQueriesDropdown = () => - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }); + cy.react('EuiComboBox', { + props: { placeholder: 'Search for a query to run, or write a new query below' }, + }); diff --git a/x-pack/plugins/osquery/cypress/tasks/live_query.ts b/x-pack/plugins/osquery/cypress/tasks/live_query.ts index d43516be2bc35e..3a1f1b0930edf9 100644 --- a/x-pack/plugins/osquery/cypress/tasks/live_query.ts +++ b/x-pack/plugins/osquery/cypress/tasks/live_query.ts @@ -13,7 +13,7 @@ export const BIG_QUERY = 'select * from processes, users limit 200;'; export const selectAllAgents = () => { cy.react('AgentsTable').find('input').should('not.be.disabled'); cy.react('AgentsTable EuiComboBox', { - props: { placeholder: 'Select agents or groups' }, + props: { placeholder: 'Select agents or groups to query' }, }).click(); cy.react('EuiFilterSelectItem').contains('All agents').should('exist'); cy.react('AgentsTable EuiComboBox').type('{downArrow}{enter}{esc}'); diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx index 75d073c4d92922..f4baf70cf5593f 100644 --- a/x-pack/plugins/osquery/public/agents/agents_table.tsx +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -7,7 +7,7 @@ import { find } from 'lodash/fp'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { EuiComboBox, EuiHealth, EuiHighlight, EuiSpacer } from '@elastic/eui'; +import { EuiComboBox, EuiHealth, EuiFormRow, EuiHighlight, EuiSpacer } from '@elastic/eui'; import deepEqual from 'fast-deep-equal'; import useDebounce from 'react-use/lib/useDebounce'; @@ -190,18 +190,20 @@ const AgentsTableComponent: React.FC = ({ agentSelection, onCh return (
- + + + {numAgentsSelected > 0 ? {generateSelectedAgentsMessage(numAgentsSelected)} : ''}
diff --git a/x-pack/plugins/osquery/public/agents/translations.ts b/x-pack/plugins/osquery/public/agents/translations.ts index 209761b4c8bdfb..643284596da1d1 100644 --- a/x-pack/plugins/osquery/public/agents/translations.ts +++ b/x-pack/plugins/osquery/public/agents/translations.ts @@ -40,7 +40,7 @@ export const AGENT_SELECTION_LABEL = i18n.translate('xpack.osquery.agents.select }); export const SELECT_AGENT_LABEL = i18n.translate('xpack.osquery.agents.selectAgentLabel', { - defaultMessage: `Select agents or groups`, + defaultMessage: `Select agents or groups to query`, }); export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearchDescription', { diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index bba443be9569ac..505550508874fc 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -254,7 +254,6 @@ const LiveQueryFormComponent: React.FC = ({ disabled={isSavedQueryDisabled} onChange={handleSavedQueryChange} /> - )} = ({ isInvalid={typeof error === 'string'} error={error} fullWidth - labelAppend={} isDisabled={!permissions.writeLiveQueries || disabled} > {!permissions.writeLiveQueries || disabled ? ( diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index 229714eaaed995..ae0baaea7f586a 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -315,8 +315,11 @@ const ResultsTableComponent: React.FC = ({ id: 'timeline', width: 38, headerCellRender: () => null, - rowCellRender: (actionProps: EuiDataGridCellValueElementProps) => { - const eventId = data[actionProps.rowIndex]._id; + rowCellRender: (actionProps) => { + const { visibleRowIndex } = actionProps as EuiDataGridCellValueElementProps & { + visibleRowIndex: number; + }; + const eventId = data[visibleRowIndex]._id; return addToTimeline({ query: ['_id', eventId], isIcon: true }); }, diff --git a/x-pack/plugins/osquery/public/saved_queries/constants.ts b/x-pack/plugins/osquery/public/saved_queries/constants.ts index 8edcfd00d1788e..5dc23354322cd6 100644 --- a/x-pack/plugins/osquery/public/saved_queries/constants.ts +++ b/x-pack/plugins/osquery/public/saved_queries/constants.ts @@ -4,6 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { i18n } from '@kbn/i18n'; export const SAVED_QUERIES_ID = 'savedQueryList'; export const SAVED_QUERY_ID = 'savedQuery'; + +export const QUERIES_DROPDOWN_LABEL = i18n.translate( + 'xpack.osquery.savedQueries.dropdown.searchFieldPlaceholder', + { + defaultMessage: `Search for a query to run, or write a new query below`, + } +); +export const QUERIES_DROPDOWN_SEARCH_FIELD_LABEL = i18n.translate( + 'xpack.osquery.savedQueries.dropdown.searchFieldLabel', + { + defaultMessage: `Query`, + } +); diff --git a/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx b/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx index 784a2375ad1a63..6722ade12ad16a 100644 --- a/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/saved_queries_dropdown.tsx @@ -9,9 +9,9 @@ import { find } from 'lodash/fp'; import { EuiCodeBlock, EuiFormRow, EuiComboBox, EuiTextColor } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { SimpleSavedObject } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import styled from 'styled-components'; +import { QUERIES_DROPDOWN_LABEL, QUERIES_DROPDOWN_SEARCH_FIELD_LABEL } from './constants'; +import { OsquerySchemaLink } from '../components/osquery_schema_link'; import { useSavedQueries } from './use_saved_queries'; import { useFormData } from '../shared_imports'; @@ -133,20 +133,14 @@ const SavedQueriesDropdownComponent: React.FC = ({ return ( - } + label={QUERIES_DROPDOWN_SEARCH_FIELD_LABEL} + labelAppend={} fullWidth > { title: i18n.translate('xpack.security.management.editRole.spacesPopoverList.popoverTitle', { defaultMessage: 'Spaces', }), - watchedItemProps: ['data-search-term'], }; if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 5a7cbd659ca7ef..8082bb3b34fc97 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { enforceOptions } from 'broadcast-channel'; import { Observable } from 'rxjs'; import type { CoreSetup } from '@kbn/core/public'; @@ -14,19 +13,15 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { FeaturesPluginStart } from '@kbn/features-plugin/public'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { managementPluginMock } from '@kbn/management-plugin/public/mocks'; +import { stubBroadcastChannel } from '@kbn/test-jest-helpers'; import { ManagementService } from './management'; import type { PluginStartDependencies } from './plugin'; import { SecurityPlugin } from './plugin'; -describe('Security Plugin', () => { - beforeAll(() => { - enforceOptions({ type: 'simulate' }); - }); - afterAll(() => { - enforceOptions(null); - }); +stubBroadcastChannel(); +describe('Security Plugin', () => { describe('#setup', () => { it('should be able to setup if optional plugins are not available', () => { const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); diff --git a/x-pack/plugins/security/public/session/session_timeout.test.ts b/x-pack/plugins/security/public/session/session_timeout.test.ts index 09b67082b1a97a..e43c1af6ac9c78 100644 --- a/x-pack/plugins/security/public/session/session_timeout.test.ts +++ b/x-pack/plugins/security/public/session/session_timeout.test.ts @@ -5,10 +5,14 @@ * 2.0. */ -import type { BroadcastChannel } from 'broadcast-channel'; - import type { ToastInputFields } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; +import { + clearBroadcastChannelInstances, + getBroadcastChannelInstances, + stubBroadcastChannel, +} from '@kbn/test-jest-helpers'; +stubBroadcastChannel(); import { SESSION_CHECK_MS, @@ -19,11 +23,8 @@ import { } from '../../common/constants'; import type { SessionInfo } from '../../common/types'; import { createSessionExpiredMock } from './session_expired.mock'; -import type { SessionState } from './session_timeout'; import { SessionTimeout, startTimer } from './session_timeout'; -jest.mock('broadcast-channel'); - jest.useFakeTimers(); jest.spyOn(window, 'addEventListener'); @@ -56,6 +57,7 @@ describe('SessionTimeout', () => { afterEach(async () => { jest.clearAllMocks(); jest.clearAllTimers(); + clearBroadcastChannelInstances(); }); test(`does not initialize when starting an anonymous path`, async () => { @@ -242,14 +244,17 @@ describe('SessionTimeout', () => { jest.advanceTimersByTime(30 * 1000); - const [broadcastChannelMock] = jest.requireMock('broadcast-channel').BroadcastChannel.mock - .instances as [BroadcastChannel]; + const [broadcastChannelMock] = getBroadcastChannelInstances(); - broadcastChannelMock.onmessage!({ - lastExtensionTime: Date.now(), - expiresInMs: 60 * 1000, - canBeExtended: true, - }); + broadcastChannelMock.onmessage!( + new MessageEvent('name', { + data: { + lastExtensionTime: Date.now(), + expiresInMs: 60 * 1000, + canBeExtended: true, + }, + }) + ); jest.advanceTimersByTime(30 * 1000); diff --git a/x-pack/plugins/security/public/session/session_timeout.ts b/x-pack/plugins/security/public/session/session_timeout.ts index be7fc4dba883cc..02e43c2fd3a836 100644 --- a/x-pack/plugins/security/public/session/session_timeout.ts +++ b/x-pack/plugins/security/public/session/session_timeout.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { BroadcastChannel as BroadcastChannelType } from 'broadcast-channel'; import type { Subscription } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; import { skip, tap, throttleTime } from 'rxjs/operators'; @@ -34,7 +33,7 @@ export interface SessionState extends Pick; + private channel?: BroadcastChannel; private isVisible = document.visibilityState !== 'hidden'; private isFetchingSessionInfo = false; @@ -77,11 +76,8 @@ export class SessionTimeout { // Subscribe to a broadcast channel for session timeout messages. // This allows us to synchronize the UX across tabs and avoid repetitive API calls. try { - const { BroadcastChannel } = await import('broadcast-channel'); - this.channel = new BroadcastChannel(`${this.tenant}/session_timeout`, { - webWorkerSupport: false, - }); - this.channel.onmessage = this.handleChannelMessage; + this.channel = new BroadcastChannel(`${this.tenant}/session_timeout`); + this.channel.onmessage = (event) => this.handleChannelMessage(event); } catch (error) { // eslint-disable-next-line no-console console.warn( @@ -108,8 +104,14 @@ export class SessionTimeout { /** * Event handler that receives session information from other browser tabs. */ - private handleChannelMessage = (message: SessionState) => { - this.sessionState$.next(message); + private handleChannelMessage = (messageEvent: MessageEvent) => { + if (this.isSessionState(messageEvent.data)) { + this.sessionState$.next(messageEvent.data); + } + }; + + private isSessionState = (data: unknown): data is SessionState => { + return typeof data === 'object' && Object.hasOwn(data ?? {}, 'canBeExtended'); }; /** diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts index 4ef5d6178d5a54..615eb3f05876e6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts @@ -6,4 +6,5 @@ */ export * from './rule_monitoring'; +export * from './rule_params'; export * from './schemas'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts new file mode 100644 index 00000000000000..b9588a26bb35b8 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { NonEmptyString } from '@kbn/securitysolution-io-ts-types'; + +// ------------------------------------------------------------------------------------------------- +// Related integrations + +/** + * Related integration is a potential dependency of a rule. It's assumed that if the user installs + * one of the related integrations of a rule, the rule might start to work properly because it will + * have source events (generated by this integration) potentially matching the rule's query. + * + * NOTE: Proper work is not guaranteed, because a related integration, if installed, can be + * configured differently or generate data that is not necessarily relevant for this rule. + * + * Related integration is a combination of a Fleet package and (optionally) one of the + * package's "integrations" that this package contains. It is represented by 3 properties: + * + * - `package`: name of the package (required, unique id) + * - `version`: version of the package (required, semver-compatible) + * - `integration`: name of the integration of this package (optional, id within the package) + * + * There are Fleet packages like `windows` that contain only one integration; in this case, + * `integration` should be unspecified. There are also packages like `aws` and `azure` that contain + * several integrations; in this case, `integration` should be specified. + * + * @example + * const x: RelatedIntegration = { + * package: 'windows', + * version: '1.5.x', + * }; + * + * @example + * const x: RelatedIntegration = { + * package: 'azure', + * version: '~1.1.6', + * integration: 'activitylogs', + * }; + */ +export type RelatedIntegration = t.TypeOf; +export const RelatedIntegration = t.exact( + t.intersection([ + t.type({ + package: NonEmptyString, + version: NonEmptyString, + }), + t.partial({ + integration: NonEmptyString, + }), + ]) +); + +/** + * Array of related integrations. + * + * @example + * const x: RelatedIntegrationArray = [ + * { + * package: 'windows', + * version: '1.5.x', + * }, + * { + * package: 'azure', + * version: '~1.1.6', + * integration: 'activitylogs', + * }, + * ]; + */ +export type RelatedIntegrationArray = t.TypeOf; +export const RelatedIntegrationArray = t.array(RelatedIntegration); + +// ------------------------------------------------------------------------------------------------- +// Required fields + +/** + * Almost all types of Security rules check source event documents for a match to some kind of + * query or filter. If a document has certain field with certain values, then it's a match and + * the rule will generate an alert. + * + * Required field is an event field that must be present in the source indices of a given rule. + * + * @example + * const standardEcsField: RequiredField = { + * name: 'event.action', + * type: 'keyword', + * ecs: true, + * }; + * + * @example + * const nonEcsField: RequiredField = { + * name: 'winlog.event_data.AttributeLDAPDisplayName', + * type: 'keyword', + * ecs: false, + * }; + */ +export type RequiredField = t.TypeOf; +export const RequiredField = t.exact( + t.type({ + name: NonEmptyString, + type: NonEmptyString, + ecs: t.boolean, + }) +); + +/** + * Array of event fields that must be present in the source indices of a given rule. + * + * @example + * const x: RequiredFieldArray = [ + * { + * name: 'event.action', + * type: 'keyword', + * ecs: true, + * }, + * { + * name: 'event.code', + * type: 'keyword', + * ecs: true, + * }, + * { + * name: 'winlog.event_data.AttributeLDAPDisplayName', + * type: 'keyword', + * ecs: false, + * }, + * ]; + */ +export type RequiredFieldArray = t.TypeOf; +export const RequiredFieldArray = t.array(RequiredField); + +// ------------------------------------------------------------------------------------------------- +// Setup guide + +/** + * Any instructions for the user for setting up their environment in order to start receiving + * source events for a given rule. + * + * It's a multiline text. Markdown is supported. + */ +export type SetupGuide = t.TypeOf; +export const SetupGuide = t.string; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index 618aee3379316c..27ebf9a608ffa1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -72,7 +72,10 @@ import { Author, event_category_override, namespace, -} from '../common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../common'; /** * Big differences between this schema and the createRulesSchema @@ -117,8 +120,11 @@ export const addPrepackagedRulesSchema = t.intersection([ meta, // defaults to "undefined" if not set during decode machine_learning_job_id, // defaults to "undefined" if not set during decode max_signals: DefaultMaxSignalsNumber, // defaults to DEFAULT_MAX_SIGNALS (100) if not set during decode + related_integrations: RelatedIntegrationArray, // defaults to "undefined" if not set during decode + required_fields: RequiredFieldArray, // defaults to "undefined" if not set during decode risk_score_mapping: DefaultRiskScoreMappingArray, // defaults to empty risk score mapping array if not set during decode rule_name_override, // defaults to "undefined" if not set during decode + setup: SetupGuide, // defaults to "undefined" if not set during decode severity_mapping: DefaultSeverityMappingArray, // defaults to empty actions array if not set during decode tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index 63c41e45e42d05..8cee4183d6ee78 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -80,7 +80,10 @@ import { timestamp_override, Author, event_category_override, -} from '../common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../common'; /** * Differences from this and the createRulesSchema are @@ -129,8 +132,11 @@ export const importRulesSchema = t.intersection([ meta, // defaults to "undefined" if not set during decode machine_learning_job_id, // defaults to "undefined" if not set during decode max_signals: DefaultMaxSignalsNumber, // defaults to DEFAULT_MAX_SIGNALS (100) if not set during decode + related_integrations: RelatedIntegrationArray, // defaults to "undefined" if not set during decode + required_fields: RequiredFieldArray, // defaults to "undefined" if not set during decode risk_score_mapping: DefaultRiskScoreMappingArray, // defaults to empty risk score mapping array if not set during decode rule_name_override, // defaults to "undefined" if not set during decode + setup: SetupGuide, // defaults to "undefined" if not set during decode severity_mapping: DefaultSeverityMappingArray, // defaults to empty actions array if not set during decode tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index 8c801e75af08c3..6678681471b386 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -61,7 +61,7 @@ import { rule_name_override, timestamp_override, event_category_override, -} from '../common/schemas'; +} from '../common'; /** * All of the patch elements should default to undefined if not set diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 69a748c3bd95c5..9aef9ac8f2651f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -67,6 +67,9 @@ import { created_by, namespace, ruleExecutionSummary, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../common'; export const createSchema = < @@ -412,6 +415,14 @@ const responseRequiredFields = { updated_by, created_at, created_by, + + // NOTE: For now, Related Integrations, Required Fields and Setup Guide are supported for prebuilt + // rules only. We don't want to allow users to edit these 3 fields via the API. If we added them + // to baseParams.defaultable, they would become a part of the request schema as optional fields. + // This is why we add them here, in order to add them only to the response schema. + related_integrations: RelatedIntegrationArray, + required_fields: RequiredFieldArray, + setup: SetupGuide, }; const responseOptionalFields = { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index 0642481b62a6aa..eeaab6dc500213 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -68,6 +68,9 @@ export const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchem rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }); export const getRulesMlSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchema => { @@ -132,6 +135,9 @@ export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts index b051eff37edc7f..8719db5036b836 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts @@ -23,7 +23,7 @@ import { wrapErrorAndRejectPromise } from './utils'; const defaultFleetAgentGenerator = new FleetAgentGenerator(); export interface IndexedFleetAgentResponse { - agents: Agent[]; + agents: Array; fleetAgentsIndex: string; } diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 5a6b20550f224f..35eb9de6d40601 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1764,7 +1764,6 @@ export class EndpointDocGenerator extends BaseDataGenerator { name: 'endpoint', version: '0.5.0', internal: false, - removable: false, install_version: '0.5.0', install_status: 'installed', install_started_at: '2020-06-24T14:41:23.098Z', diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 32c55e22ae7c91..de2de9bd781601 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -442,7 +442,9 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response severity, query, } = ruleResponse.body; - const rule = { + + // NOTE: Order of the properties in this object matters for the tests to work. + const rule: RulesSchema = { id, updated_at: updatedAt, updated_by: updatedBy, @@ -469,6 +471,9 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response version: 1, exceptions_list: [], immutable: false, + related_integrations: [], + required_fields: [], + setup: '', type: 'query', language: 'kuery', index: getIndexPatterns(), @@ -476,6 +481,8 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response throttle: 'no_actions', actions: [], }; + + // NOTE: Order of the properties in this object matters for the tests to work. const details = { exported_count: 1, exported_rules_count: 1, diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 550ec608a76cb5..6598e0dc294261 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -10,7 +10,8 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; import { LicenseType } from '@kbn/licensing-plugin/common/types'; import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; -import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import { AppDeepLink, AppNavLinkStatus, AppUpdater, Capabilities } from '@kbn/core/public'; +import { Subject } from 'rxjs'; import { SecurityPageName } from '../types'; import { OVERVIEW, @@ -63,6 +64,8 @@ import { RULES_CREATE_PATH, } from '../../../common/constants'; import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { subscribeAppLinks } from '../../common/links'; +import { AppLinkItems } from '../../common/links/types'; const FEATURE = { general: `${SERVER_APP_ID}.show`, @@ -553,3 +556,37 @@ export function isPremiumLicense(licenseType?: LicenseType): boolean { licenseType === 'trial' ); } + +/** + * New deep links code starts here. + * All the code above will be removed once the appLinks migration is over. + * The code below manages the new implementation using the unified appLinks. + */ + +const formatDeepLinks = (appLinks: AppLinkItems): AppDeepLink[] => + appLinks.map((appLink) => ({ + id: appLink.id, + path: appLink.path, + title: appLink.title, + navLinkStatus: appLink.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + searchable: !appLink.globalSearchDisabled, + ...(appLink.globalSearchKeywords != null ? { keywords: appLink.globalSearchKeywords } : {}), + ...(appLink.globalNavOrder != null ? { order: appLink.globalNavOrder } : {}), + ...(appLink.links && appLink.links?.length + ? { + deepLinks: formatDeepLinks(appLink.links), + } + : {}), + })); + +/** + * Registers any change in appLinks to be updated in app deepLinks + */ +export const registerDeepLinksUpdater = (appUpdater$: Subject) => { + subscribeAppLinks((appLinks) => { + appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // needed to prevent main security link to switch to visible after update + deepLinks: formatDeepLinks(appLinks), + })); + }); +}; diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index 3b436d2bdefc14..8d7d9daad550d7 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -26,6 +26,7 @@ import { gutterTimeline } from '../../../common/lib/helpers'; import { useKibana } from '../../../common/lib/kibana'; import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view'; import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks'; +import { useIsGroupedNavigationEnabled } from '../../../common/components/navigation/helpers'; const NO_DATA_PAGE_MAX_WIDTH = 950; @@ -44,8 +45,7 @@ const NO_DATA_PAGE_TEMPLATE_PROPS = { */ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ $isShowingTimelineOverlay?: boolean; - $isTimelineBottomBarVisible?: boolean; - $isPolicySettingsVisible?: boolean; + $addBottomPadding?: boolean; }>` .${BOTTOM_BAR_CLASSNAME} { animation: 'none !important'; // disable the default bottom bar slide animation @@ -63,19 +63,8 @@ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ } // If the bottom bar is visible add padding to the navigation - ${({ $isTimelineBottomBarVisible }) => - $isTimelineBottomBarVisible && - ` - @media (min-width: 768px) { - .kbnPageTemplateSolutionNav { - padding-bottom: ${gutterTimeline}; - } - } - `} - - // If the policy settings bottom bar is visible add padding to the navigation - ${({ $isPolicySettingsVisible }) => - $isPolicySettingsVisible && + ${({ $addBottomPadding }) => + $addBottomPadding && ` @media (min-width: 768px) { .kbnPageTemplateSolutionNav { @@ -98,6 +87,9 @@ export const SecuritySolutionTemplateWrapper: React.FC getTimelineShowStatus(state, TimelineId.active) ); + const isGroupedNavEnabled = useIsGroupedNavigationEnabled(); + const addBottomPadding = + isTimelineBottomBarVisible || isPolicySettingsVisible || isGroupedNavEnabled; const userHasSecuritySolutionVisible = useKibana().services.application.capabilities.siem.show; const showEmptyState = useShowPagesWithEmptyView(); @@ -117,9 +109,8 @@ export const SecuritySolutionTemplateWrapper: React.FC diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 9857e7160a2097..354ba438ff52a2 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -23,7 +23,7 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', { }); export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', { - defaultMessage: 'Getting started', + defaultMessage: 'Get started', }); export const THREAT_HUNTING = i18n.translate('xpack.securitySolution.navigation.threatHunting', { diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts index 9ed7a1f3980a6d..bafaee6baa583c 100644 --- a/x-pack/plugins/security_solution/public/cases/links.ts +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -6,8 +6,8 @@ */ import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; -import { CASES_PATH, SecurityPageName } from '../../common/constants'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { CASES_FEATURE_ID, CASES_PATH, SecurityPageName } from '../../common/constants'; +import { LinkItem } from '../common/links/types'; export const getCasesLinkItems = (): LinkItem => { const casesLinks = getCasesDeepLinks({ @@ -16,15 +16,17 @@ export const getCasesLinkItems = (): LinkItem => { [SecurityPageName.case]: { globalNavEnabled: true, globalNavOrder: 9006, - features: [FEATURE.casesRead], + capabilities: [`${CASES_FEATURE_ID}.read_cases`], }, [SecurityPageName.caseConfigure]: { - features: [FEATURE.casesCrud], + capabilities: [`${CASES_FEATURE_ID}.crud_cases`], licenseType: 'gold', + sideNavDisabled: true, hideTimeline: true, }, [SecurityPageName.caseCreate]: { - features: [FEATURE.casesCrud], + capabilities: [`${CASES_FEATURE_ID}.crud_cases`], + sideNavDisabled: true, hideTimeline: true, }, }, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx index 1c9c0292ed9124..d4677d22485b4b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx @@ -16,6 +16,8 @@ import { AGENT_STATUS_FIELD_NAME } from '../../../../timelines/components/timeli const FIELDS_WITHOUT_ACTIONS: { [field: string]: boolean } = { [AGENT_STATUS_FIELD_NAME]: true }; +const style = { flexGrow: 0 }; + export const SummaryValueCell: React.FC = ({ data, eventId, @@ -25,32 +27,36 @@ export const SummaryValueCell: React.FC = ({ timelineId, values, isReadOnly, -}) => ( - <> - - {timelineId !== TimelineId.active && !isReadOnly && !FIELDS_WITHOUT_ACTIONS[data.field] && ( - { + const hoverActionsEnabled = !FIELDS_WITHOUT_ACTIONS[data.field]; + + return ( + <> + - )} - -); + {timelineId !== TimelineId.active && !isReadOnly && hoverActionsEnabled && ( + + )} + + ); +}; SummaryValueCell.displayName = 'SummaryValueCell'; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts index ff7aa7581fc4bb..41b62e85898541 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts @@ -7,11 +7,12 @@ import { renderHook } from '@testing-library/react-hooks'; import { SecurityPageName } from '../../../app/types'; -import { NavLinkItem } from '../../links/types'; +import { AppLinkItems } from '../../links'; import { TestProviders } from '../../mock'; import { useAppNavLinks, useAppRootNavLink } from './nav_links'; +import { NavLinkItem } from './types'; -const mockNavLinks = [ +const mockNavLinks: AppLinkItems = [ { description: 'description', id: SecurityPageName.administration, @@ -22,6 +23,10 @@ const mockNavLinks = [ links: [], path: '/path_2', title: 'title 2', + sideNavDisabled: true, + landingIcon: 'someicon', + landingImage: 'someimage', + skipUrlState: true, }, ], path: '/path', @@ -30,7 +35,7 @@ const mockNavLinks = [ ]; jest.mock('../../links', () => ({ - getNavLinkItems: () => mockNavLinks, + useAppLinks: () => mockNavLinks, })); const renderUseAppNavLinks = () => @@ -44,11 +49,47 @@ const renderUseAppRootNavLink = (id: SecurityPageName) => describe('useAppNavLinks', () => { it('should return all nav links', () => { const { result } = renderUseAppNavLinks(); - expect(result.current).toEqual(mockNavLinks); + expect(result.current).toMatchInlineSnapshot(` + Array [ + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + }, + ] + `); }); it('should return a root nav links', () => { const { result } = renderUseAppRootNavLink(SecurityPageName.administration); - expect(result.current).toEqual(mockNavLinks[0]); + expect(result.current).toMatchInlineSnapshot(` + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + } + `); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts index efdf72a1f7926b..db8b5788b04d65 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts @@ -5,21 +5,35 @@ * 2.0. */ -import { useKibana } from '../../lib/kibana'; -import { useEnableExperimental } from '../../hooks/use_experimental_features'; -import { useLicense } from '../../hooks/use_license'; -import { getNavLinkItems } from '../../links'; +import { useMemo } from 'react'; +import { useAppLinks } from '../../links'; import type { SecurityPageName } from '../../../app/types'; -import type { NavLinkItem } from '../../links/types'; +import { NavLinkItem } from './types'; +import { AppLinkItems } from '../../links/types'; export const useAppNavLinks = (): NavLinkItem[] => { - const license = useLicense(); - const enableExperimental = useEnableExperimental(); - const capabilities = useKibana().services.application.capabilities; - - return getNavLinkItems({ enableExperimental, license, capabilities }); + const appLinks = useAppLinks(); + const navLinks = useMemo(() => formatNavLinkItems(appLinks), [appLinks]); + return navLinks; }; export const useAppRootNavLink = (linkId: SecurityPageName): NavLinkItem | undefined => { return useAppNavLinks().find(({ id }) => id === linkId); }; + +const formatNavLinkItems = (appLinks: AppLinkItems): NavLinkItem[] => + appLinks.map((link) => ({ + id: link.id, + title: link.title, + ...(link.categories != null ? { categories: link.categories } : {}), + ...(link.description != null ? { description: link.description } : {}), + ...(link.sideNavDisabled === true ? { disabled: true } : {}), + ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), + ...(link.landingImage != null ? { image: link.landingImage } : {}), + ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), + ...(link.links && link.links.length + ? { + links: formatNavLinkItems(link.links), + } + : {}), + })); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx new file mode 100644 index 00000000000000..de96338ef98e6a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { SVGProps } from 'react'; + +export const EuiIconLaunch: React.FC> = ({ ...props }) => ( + + + + + + + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts new file mode 100644 index 00000000000000..a2c866e604e166 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SecuritySideNav } from './security_side_nav'; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx new file mode 100644 index 00000000000000..c0ebd0722f725e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { SecurityPageName } from '../../../../app/types'; +import { TestProviders } from '../../../mock'; +import { SecuritySideNav } from './security_side_nav'; +import { SolutionGroupedNavProps } from '../solution_grouped_nav/solution_grouped_nav'; +import { NavLinkItem } from '../types'; + +const manageNavLink: NavLinkItem = { + id: SecurityPageName.administration, + title: 'manage', + description: 'manage description', + categories: [{ label: 'test category', linkIds: [SecurityPageName.endpoints] }], + links: [ + { + id: SecurityPageName.endpoints, + title: 'title 2', + description: 'description 2', + }, + ], +}; +const alertsNavLink: NavLinkItem = { + id: SecurityPageName.alerts, + title: 'alerts', + description: 'alerts description', +}; + +const mockSolutionGroupedNav = jest.fn((_: SolutionGroupedNavProps) => <>); +jest.mock('../solution_grouped_nav', () => ({ + SolutionGroupedNav: (props: SolutionGroupedNavProps) => mockSolutionGroupedNav(props), +})); +const mockUseRouteSpy = jest.fn(() => [{ pageName: SecurityPageName.alerts }]); +jest.mock('../../../utils/route/use_route_spy', () => ({ + useRouteSpy: () => mockUseRouteSpy(), +})); + +const mockedUseCanSeeHostIsolationExceptionsMenu = jest.fn(); +jest.mock('../../../../management/pages/host_isolation_exceptions/view/hooks', () => ({ + useCanSeeHostIsolationExceptionsMenu: () => mockedUseCanSeeHostIsolationExceptionsMenu(), +})); +jest.mock('../../../links', () => ({ + getAncestorLinksInfo: (id: string) => [{ id }], +})); + +const mockUseAppNavLinks = jest.fn((): NavLinkItem[] => [alertsNavLink, manageNavLink]); +jest.mock('../nav_links', () => ({ + useAppNavLinks: () => mockUseAppNavLinks(), +})); +jest.mock('../../links', () => ({ + useGetSecuritySolutionLinkProps: + () => + ({ deepLinkId }: { deepLinkId: SecurityPageName }) => ({ + href: `/${deepLinkId}`, + }), +})); + +const renderNav = () => + render(, { + wrapper: TestProviders, + }); + +describe('SecuritySideNav', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render main items', () => { + mockUseAppNavLinks.mockReturnValueOnce([alertsNavLink]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith({ + selectedId: SecurityPageName.alerts, + items: [ + { + id: SecurityPageName.alerts, + label: 'alerts', + href: '/alerts', + }, + ], + footerItems: [], + }); + }); + + it('should render the loader if items are still empty', () => { + mockUseAppNavLinks.mockReturnValueOnce([]); + const result = renderNav(); + expect(result.getByTestId('sideNavLoader')).toBeInTheDocument(); + expect(mockSolutionGroupedNav).not.toHaveBeenCalled(); + }); + + it('should render with selected id', () => { + mockUseRouteSpy.mockReturnValueOnce([{ pageName: SecurityPageName.administration }]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + selectedId: SecurityPageName.administration, + }) + ); + }); + + it('should render footer items', () => { + mockUseAppNavLinks.mockReturnValueOnce([manageNavLink]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [ + { + id: SecurityPageName.endpoints, + label: 'title 2', + description: 'description 2', + href: '/endpoints', + }, + ], + }, + ], + }) + ); + }); + + it('should not render disabled items', () => { + mockUseAppNavLinks.mockReturnValueOnce([ + { ...alertsNavLink, disabled: true }, + { + ...manageNavLink, + links: [ + { + id: SecurityPageName.endpoints, + title: 'title 2', + description: 'description 2', + disabled: true, + }, + ], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [], + }, + ], + }) + ); + }); + + it('should render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is true', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValue(true); + const hostIsolationExceptionsLink = { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions', + description: 'test description hostIsolationExceptions', + }; + + mockUseAppNavLinks.mockReturnValueOnce([ + { + ...manageNavLink, + links: [hostIsolationExceptionsLink], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [ + { + id: hostIsolationExceptionsLink.id, + label: hostIsolationExceptionsLink.title, + description: hostIsolationExceptionsLink.description, + href: '/host_isolation_exceptions', + }, + ], + }, + ], + }) + ); + }); + + it('should not render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is false', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValue(false); + const hostIsolationExceptionsLink = { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions', + description: 'test description hostIsolationExceptions', + }; + + mockUseAppNavLinks.mockReturnValueOnce([ + { + ...manageNavLink, + links: [hostIsolationExceptionsLink], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [], + }, + ], + }) + ); + }); + + it('should render custom item', () => { + mockUseAppNavLinks.mockReturnValueOnce([ + { id: SecurityPageName.landing, title: 'get started' }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.landing, + render: expect.any(Function), + }, + ], + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx new file mode 100644 index 00000000000000..b9173270e381ee --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback } from 'react'; +import { EuiHorizontalRule, EuiListGroupItem, EuiLoadingSpinner } from '@elastic/eui'; +import { SecurityPageName } from '../../../../app/types'; +import { getAncestorLinksInfo } from '../../../links'; +import { useRouteSpy } from '../../../utils/route/use_route_spy'; +import { SecuritySolutionLinkAnchor, useGetSecuritySolutionLinkProps } from '../../links'; +import { useAppNavLinks } from '../nav_links'; +import { SolutionGroupedNav } from '../solution_grouped_nav'; +import { CustomSideNavItem, DefaultSideNavItem, SideNavItem } from '../solution_grouped_nav/types'; +import { NavLinkItem } from '../types'; +import { EuiIconLaunch } from './icons/launch'; +import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pages/host_isolation_exceptions/view/hooks'; + +const isFooterNavItem = (id: SecurityPageName) => + id === SecurityPageName.landing || id === SecurityPageName.administration; + +type FormatSideNavItems = (navItems: NavLinkItem) => SideNavItem; + +/** + * Renders the navigation item for "Get Started" custom link + */ +const GetStartedCustomLinkComponent: React.FC<{ + isSelected: boolean; + title: string; +}> = ({ isSelected, title }) => ( + + + + +); +const GetStartedCustomLink = React.memo(GetStartedCustomLinkComponent); + +/** + * Returns a function to format generic `NavLinkItem` array to the `SideNavItem` type + */ +const useFormatSideNavItem = (): FormatSideNavItems => { + const hideHostIsolationExceptions = !useCanSeeHostIsolationExceptionsMenu(); + const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); // adds href and onClick props + + const formatSideNavItem: FormatSideNavItems = useCallback( + (navLinkItem) => { + const formatDefaultItem = (navItem: NavLinkItem): DefaultSideNavItem => ({ + id: navItem.id, + label: navItem.title, + ...getSecuritySolutionLinkProps({ deepLinkId: navItem.id }), + ...(navItem.categories && navItem.categories.length > 0 + ? { categories: navItem.categories } + : {}), + ...(navItem.links && navItem.links.length > 0 + ? { + items: navItem.links + .filter( + (link) => + !link.disabled && + !( + link.id === SecurityPageName.hostIsolationExceptions && + hideHostIsolationExceptions + ) + ) + .map((panelNavItem) => ({ + id: panelNavItem.id, + label: panelNavItem.title, + description: panelNavItem.description, + ...getSecuritySolutionLinkProps({ deepLinkId: panelNavItem.id }), + })), + } + : {}), + }); + + const formatGetStartedItem = (navItem: NavLinkItem): CustomSideNavItem => ({ + id: navItem.id, + render: (isSelected) => ( + + ), + }); + + if (navLinkItem.id === SecurityPageName.landing) { + return formatGetStartedItem(navLinkItem); + } + return formatDefaultItem(navLinkItem); + }, + [getSecuritySolutionLinkProps, hideHostIsolationExceptions] + ); + + return formatSideNavItem; +}; + +/** + * Returns the formatted `items` and `footerItems` to be rendered in the navigation + */ +const useSideNavItems = () => { + const appNavLinks = useAppNavLinks(); + const formatSideNavItem = useFormatSideNavItem(); + + const sideNavItems = useMemo(() => { + const mainNavItems: SideNavItem[] = []; + const footerNavItems: SideNavItem[] = []; + appNavLinks.forEach((appNavLink) => { + if (appNavLink.disabled) { + return; + } + + if (isFooterNavItem(appNavLink.id)) { + footerNavItems.push(formatSideNavItem(appNavLink)); + } else { + mainNavItems.push(formatSideNavItem(appNavLink)); + } + }); + return [mainNavItems, footerNavItems]; + }, [appNavLinks, formatSideNavItem]); + + return sideNavItems; +}; + +const useSelectedId = (): SecurityPageName => { + const [{ pageName }] = useRouteSpy(); + const selectedId = useMemo(() => { + const [rootLinkInfo] = getAncestorLinksInfo(pageName as SecurityPageName); + return rootLinkInfo?.id ?? ''; + }, [pageName]); + + return selectedId; +}; + +/** + * Main security navigation component. + * It takes the links to render from the generic application `links` configs. + */ +export const SecuritySideNav: React.FC = () => { + const [items, footerItems] = useSideNavItems(); + const selectedId = useSelectedId(); + + if (items.length === 0 && footerItems.length === 0) { + return ; + } + + return ; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx index f141264bd97e43..e41b566bbc7c8d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx @@ -9,15 +9,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; import { TestProviders } from '../../../mock'; -import { NavItem } from './solution_grouped_nav_item'; import { SolutionGroupedNav, SolutionGroupedNavProps } from './solution_grouped_nav'; +import { SideNavItem } from './types'; const mockUseShowTimeline = jest.fn((): [boolean] => [false]); jest.mock('../../../utils/timeline/use_show_timeline', () => ({ useShowTimeline: () => mockUseShowTimeline(), })); -const mockItems: NavItem[] = [ +const mockItems: SideNavItem[] = [ { id: SecurityPageName.dashboardsLanding, label: 'Dashboards', diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx index fcfcc9d6b1b4b5..073723b80f518f 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx @@ -15,22 +15,38 @@ import { } from '@elastic/eui'; import classNames from 'classnames'; -import { SolutionGroupedNavPanel } from './solution_grouped_nav_panel'; +import { SolutionNavPanel } from './solution_grouped_nav_panel'; import { EuiListGroupItemStyled } from './solution_grouped_nav.styles'; -import { - isCustomNavItem, - isDefaultNavItem, - NavItem, - PortalNavItem, -} from './solution_grouped_nav_item'; +import { DefaultSideNavItem, SideNavItem, isCustomItem, isDefaultItem } from './types'; import { EuiIconSpaces } from './icons/spaces'; +import type { LinkCategories } from '../../../links'; export interface SolutionGroupedNavProps { - items: NavItem[]; + items: SideNavItem[]; + selectedId: string; + footerItems?: SideNavItem[]; +} +export interface SolutionNavItemsProps { + items: SideNavItem[]; selectedId: string; - footerItems?: NavItem[]; + activePanelNavId: ActivePanelNav; + isMobileSize: boolean; + navItemsById: NavItemsById; + onOpenPanelNav: (id: string) => void; } -type ActivePortalNav = string | null; +export interface SolutionNavItemProps { + item: SideNavItem; + isSelected: boolean; + isActive: boolean; + hasPanelNav: boolean; + onOpenPanelNav: (id: string) => void; +} + +type ActivePanelNav = string | null; +type NavItemsById = Record< + string, + { title: string; panelItems: DefaultSideNavItem[]; categories?: LinkCategories } +>; export const SolutionGroupedNavComponent: React.FC = ({ items, @@ -39,41 +55,40 @@ export const SolutionGroupedNavComponent: React.FC = ({ }) => { const isMobileSize = useIsWithinBreakpoints(['xs', 's']); - const [activePortalNavId, setActivePortalNavId] = useState(null); - const activePortalNavIdRef = useRef(null); + const [activePanelNavId, setActivePanelNavId] = useState(null); + const activePanelNavIdRef = useRef(null); - const openPortalNav = (navId: string) => { - activePortalNavIdRef.current = navId; - setActivePortalNavId(navId); + const openPanelNav = (id: string) => { + activePanelNavIdRef.current = id; + setActivePanelNavId(id); }; - const closePortalNav = () => { - activePortalNavIdRef.current = null; - setActivePortalNavId(null); - }; + const onClosePanelNav = useCallback(() => { + activePanelNavIdRef.current = null; + setActivePanelNavId(null); + }, []); - const onClosePortalNav = useCallback(() => { - const currentPortalNavId = activePortalNavIdRef.current; + const onOutsidePanelClick = useCallback(() => { + const currentPanelNavId = activePanelNavIdRef.current; setTimeout(() => { // This event is triggered on outside click. // Closing the side nav at the end of event loop to make sure it - // closes also if the active "nav group" button has been clicked (toggle), - // but it does not close if any some other "nav group" open button has been clicked. - if (activePortalNavIdRef.current === currentPortalNavId) { - closePortalNav(); + // closes also if the active panel button has been clicked (toggle), + // but it does not close if any any other panel open button has been clicked. + if (activePanelNavIdRef.current === currentPanelNavId) { + onClosePanelNav(); } }); - }, []); + }, [onClosePanelNav]); - const navItemsById = useMemo( + const navItemsById = useMemo( () => - [...items, ...footerItems].reduce< - Record - >((acc, navItem) => { - if (isDefaultNavItem(navItem) && navItem.items && navItem.items.length > 0) { + [...items, ...footerItems].reduce((acc, navItem) => { + if (isDefaultItem(navItem) && navItem.items && navItem.items.length > 0) { acc[navItem.id] = { title: navItem.label, - subItems: navItem.items, + panelItems: navItem.items, + categories: navItem.categories, }; } return acc; @@ -82,67 +97,20 @@ export const SolutionGroupedNavComponent: React.FC = ({ ); const portalNav = useMemo(() => { - if (activePortalNavId == null || !navItemsById[activePortalNavId]) { + if (activePanelNavId == null || !navItemsById[activePanelNavId]) { return null; } - const { subItems, title } = navItemsById[activePortalNavId]; - return ; - }, [activePortalNavId, navItemsById, onClosePortalNav]); - - const renderNavItem = useCallback( - (navItem: NavItem) => { - if (isCustomNavItem(navItem)) { - return {navItem.render()}; - } - const { id, href, label, onClick } = navItem; - const isActive = activePortalNavId === id; - const isCurrentNav = selectedId === id; - - const itemClassNames = classNames('solutionGroupedNavItem', { - 'solutionGroupedNavItem--isActive': isActive, - 'solutionGroupedNavItem--isPrimary': isCurrentNav, - }); - const buttonClassNames = classNames('solutionGroupedNavItemButton'); - - return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - { - ev.preventDefault(); - ev.stopPropagation(); - openPortalNav(id); - }, - iconType: EuiIconSpaces, - iconSize: 'm', - 'aria-label': 'Toggle group nav', - 'data-test-subj': `groupedNavItemButton-${id}`, - alwaysShow: true, - }, - } - : {})} - /> - - ); - }, - [activePortalNavId, isMobileSize, navItemsById, selectedId] - ); + const { panelItems, title, categories } = navItemsById[activePanelNavId]; + return ( + + ); + }, [activePanelNavId, navItemsById, onClosePanelNav, onOutsidePanelClick]); return ( <> @@ -150,10 +118,28 @@ export const SolutionGroupedNavComponent: React.FC = ({ - {items.map(renderNavItem)} + + + - {footerItems.map(renderNavItem)} + + + @@ -163,5 +149,84 @@ export const SolutionGroupedNavComponent: React.FC = ({ ); }; - export const SolutionGroupedNav = React.memo(SolutionGroupedNavComponent); + +const SolutionNavItems: React.FC = ({ + items, + selectedId, + activePanelNavId, + isMobileSize, + navItemsById, + onOpenPanelNav, +}) => ( + <> + {items.map((item) => ( + + ))} + +); + +const SolutionNavItemComponent: React.FC = ({ + item, + isSelected, + isActive, + hasPanelNav, + onOpenPanelNav, +}) => { + if (isCustomItem(item)) { + return {item.render(isSelected)}; + } + const { id, href, label, onClick } = item; + + const itemClassNames = classNames('solutionGroupedNavItem', { + 'solutionGroupedNavItem--isActive': isActive, + 'solutionGroupedNavItem--isPrimary': isSelected, + }); + const buttonClassNames = classNames('solutionGroupedNavItemButton'); + + const onButtonClick: React.MouseEventHandler = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + onOpenPanelNav(id); + }; + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + + + ); +}; +const SolutionNavItem = React.memo(SolutionNavItemComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx deleted file mode 100644 index df7e08ad46f95a..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useGetSecuritySolutionLinkProps } from '../../links'; -import { SecurityPageName } from '../../../../../common/constants'; - -export type NavItemCategories = Array<{ label: string; itemIds: string[] }>; -export interface DefaultNavItem { - id: string; - label: string; - href: string; - onClick?: React.MouseEventHandler; - items?: PortalNavItem[]; - categories?: NavItemCategories; -} - -export interface CustomNavItem { - id: string; - render: () => React.ReactNode; -} - -export type NavItem = DefaultNavItem | CustomNavItem; - -export interface PortalNavItem { - id: string; - label: string; - href: string; - onClick?: React.MouseEventHandler; - description?: string; -} - -export const isCustomNavItem = (navItem: NavItem): navItem is CustomNavItem => 'render' in navItem; -export const isDefaultNavItem = (navItem: NavItem): navItem is DefaultNavItem => - !isCustomNavItem(navItem); - -export const useNavItems: () => NavItem[] = () => { - const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); - return [ - { - id: SecurityPageName.dashboardsLanding, - label: 'Dashboards', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.dashboardsLanding }), - items: [ - { - id: 'overview', - label: 'Overview', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.overview }), - }, - { - id: 'detection_response', - label: 'Detection & Response', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.detectionAndResponse }), - }, - // TODO: add the cloudPostureFindings to the config here - // { - // id: SecurityPageName.cloudPostureFindings, - // label: 'Cloud Posture Findings', - // description: 'The description goes here', - // ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.cloudPostureFindings }), - // }, - ], - }, - { - id: SecurityPageName.alerts, - label: 'Alerts', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.alerts }), - }, - { - id: SecurityPageName.timelines, - label: 'Timelines', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.timelines }), - }, - { - id: SecurityPageName.case, - label: 'Cases', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.case }), - }, - { - id: SecurityPageName.threatHuntingLanding, - label: 'Threat Hunting', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.threatHuntingLanding }), - items: [ - { - id: SecurityPageName.hosts, - label: 'Hosts', - description: - 'Computer or other device that communicates with other hosts on a network. Hosts on a network include clients and servers -- that send or receive data, services or applications.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hosts }), - }, - { - id: SecurityPageName.network, - label: 'Network', - description: - 'The action or process of interacting with others to exchange information and develop professional or social contacts.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.network }), - }, - { - id: SecurityPageName.users, - label: 'Users', - description: 'Sudo commands dashboard from the Logs System integration.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.users }), - }, - ], - }, - // TODO: implement footer and move management - { - id: SecurityPageName.administration, - label: 'Manage', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.administration }), - categories: [ - { label: 'SIEM', itemIds: [SecurityPageName.rules, SecurityPageName.exceptions] }, - { - label: 'ENDPOINTS', - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, - ], - items: [ - { - id: SecurityPageName.rules, - label: 'Rules', - description: 'The description here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.rules }), - }, - { - id: SecurityPageName.exceptions, - label: 'Exceptions', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.exceptions }), - }, - { - id: SecurityPageName.endpoints, - label: 'Endpoints', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.endpoints }), - }, - { - id: SecurityPageName.policies, - label: 'Policies', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.policies }), - }, - { - id: SecurityPageName.trustedApps, - label: 'Trusted applications', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.trustedApps }), - }, - { - id: SecurityPageName.eventFilters, - label: 'Event filters', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.eventFilters }), - }, - { - id: SecurityPageName.blocklist, - label: 'Blocklist', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.blocklist }), - }, - { - id: SecurityPageName.hostIsolationExceptions, - label: 'Host Isolation IP exceptions', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hostIsolationExceptions }), - }, - ], - }, - ]; -}; - -export const useFooterNavItems: () => NavItem[] = () => { - // TODO: implement footer items - return []; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx index 93d46c35d6bed2..8215d9c0b9f406 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx @@ -9,18 +9,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; import { TestProviders } from '../../../mock'; -import { PortalNavItem } from './solution_grouped_nav_item'; -import { - SolutionGroupedNavPanel, - SolutionGroupedNavPanelProps, -} from './solution_grouped_nav_panel'; +import { SolutionNavPanel, SolutionNavPanelProps } from './solution_grouped_nav_panel'; +import { DefaultSideNavItem } from './types'; const mockUseShowTimeline = jest.fn((): [boolean] => [false]); jest.mock('../../../utils/timeline/use_show_timeline', () => ({ useShowTimeline: () => mockUseShowTimeline(), })); -const mockItems: PortalNavItem[] = [ +const mockItems: DefaultSideNavItem[] = [ { id: SecurityPageName.hosts, label: 'Hosts', @@ -37,14 +34,16 @@ const mockItems: PortalNavItem[] = [ const PANEL_TITLE = 'test title'; const mockOnClose = jest.fn(); -const renderNavPanel = (props: Partial = {}) => +const mockOnOutsideClick = jest.fn(); +const renderNavPanel = (props: Partial = {}) => render( <>
- , @@ -112,7 +111,7 @@ describe('SolutionGroupedNav', () => { const result = renderNavPanel(); result.getByTestId('outsideClickDummy').click(); waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnOutsideClick).toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx index c1615a97264eb5..a418f666d2782e 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx @@ -13,8 +13,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFocusTrap, + EuiHorizontalRule, EuiOutsideClickDetector, EuiPortal, + EuiSpacer, EuiTitle, EuiWindowEvent, keys, @@ -22,18 +24,39 @@ import { } from '@elastic/eui'; import classNames from 'classnames'; import { EuiPanelStyled } from './solution_grouped_nav_panel.styles'; -import { PortalNavItem } from './solution_grouped_nav_item'; import { useShowTimeline } from '../../../utils/timeline/use_show_timeline'; +import type { DefaultSideNavItem } from './types'; +import type { LinkCategories } from '../../../links/types'; -export interface SolutionGroupedNavPanelProps { +export interface SolutionNavPanelProps { onClose: () => void; + onOutsideClick: () => void; title: string; - items: PortalNavItem[]; + items: DefaultSideNavItem[]; + categories?: LinkCategories; +} +export interface SolutionNavPanelCategoriesProps { + categories: LinkCategories; + items: DefaultSideNavItem[]; + onClose: () => void; +} +export interface SolutionNavPanelItemsProps { + items: DefaultSideNavItem[]; + onClose: () => void; +} +export interface SolutionNavPanelItemProps { + item: DefaultSideNavItem; + onClose: () => void; } -const SolutionGroupedNavPanelComponent: React.FC = ({ +/** + * Renders the side navigation panel for secondary links + */ +const SolutionNavPanelComponent: React.FC = ({ onClose, + onOutsideClick, title, + categories, items, }) => { const [hasTimelineBar] = useShowTimeline(); @@ -41,9 +64,7 @@ const SolutionGroupedNavPanelComponent: React.FC = const isTimelineVisible = hasTimelineBar && isLargerBreakpoint; const panelClasses = classNames('eui-yScroll'); - /** - * ESC key closes SideNav - */ + // ESC key closes PanelNav const onKeyDown = useCallback( (ev: KeyboardEvent) => { if (ev.key === keys.ESCAPE) { @@ -58,7 +79,7 @@ const SolutionGroupedNavPanelComponent: React.FC = - onClose()}> + = - {items.map(({ id, href, onClick, label, description }: PortalNavItem) => ( - - - { - onClose(); - if (onClick) { - onClick(ev); - } - }} - > - {label} - - - {description} - - ))} + {categories ? ( + + ) : ( + + )} @@ -105,5 +116,61 @@ const SolutionGroupedNavPanelComponent: React.FC = ); }; +export const SolutionNavPanel = React.memo(SolutionNavPanelComponent); + +const SolutionNavPanelCategories: React.FC = ({ + categories, + items, + onClose, +}) => { + const itemsMap = new Map(items.map((item) => [item.id, item])); + + return ( + <> + {categories.map(({ label, linkIds }) => { + const links = linkIds.reduce((acc, linkId) => { + const link = itemsMap.get(linkId); + if (link) { + acc.push(link); + } + return acc; + }, []); + + return ( + + +

{label}

+
+ + + +
+ ); + })} + + ); +}; -export const SolutionGroupedNavPanel = React.memo(SolutionGroupedNavPanelComponent); +const SolutionNavPanelItems: React.FC = ({ items, onClose }) => ( + <> + {items.map(({ id, href, onClick, label, description }) => ( + + + { + onClose(); + if (onClick) { + onClick(ev); + } + }} + > + {label} + + + {description} + + ))} + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts new file mode 100644 index 00000000000000..a16bad9126d095 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { SecurityPageName } from '../../../../app/types'; +import type { LinkCategories } from '../../../links/types'; + +export interface DefaultSideNavItem { + id: SecurityPageName; + label: string; + href: string; + onClick?: React.MouseEventHandler; + description?: string; + items?: DefaultSideNavItem[]; + categories?: LinkCategories; +} + +export interface CustomSideNavItem { + id: string; + render: (isSelected: boolean) => React.ReactNode; +} + +export type SideNavItem = DefaultSideNavItem | CustomSideNavItem; + +export const isCustomItem = (navItem: SideNavItem): navItem is CustomSideNavItem => + 'render' in navItem; +export const isDefaultItem = (navItem: SideNavItem): navItem is DefaultSideNavItem => + !isCustomItem(navItem); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 91edd1feea2dad..85d504165484b0 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { IconType } from '@elastic/eui'; import { UrlStateType } from '../url_state/constants'; import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; +import { LinkCategories } from '../../links'; export interface TabNavigationComponentProps { pageName: string; @@ -76,10 +78,14 @@ export type GetUrlForApp = ( ) => string; export type NavigateToUrl = (url: string) => void; - -export interface NavigationCategory { - label: string; - linkIds: readonly SecurityPageName[]; +export interface NavLinkItem { + categories?: LinkCategories; + description?: string; + disabled?: boolean; + icon?: IconType; + id: SecurityPageName; + links?: NavLinkItem[]; + image?: string; + title: string; + skipUrlState?: boolean; } - -export type NavigationCategories = Readonly; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap index cadb9057ccbccf..d50b07ca56089b 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap @@ -14,7 +14,7 @@ Object { "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", "id": "get_started", "isSelected": false, - "name": "Getting started", + "name": "Get started", "onClick": [Function], }, Object { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx index 1dbcf929ed81fa..1123fd50a53e66 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx @@ -11,9 +11,8 @@ import { i18n } from '@kbn/i18n'; import { KibanaPageTemplateProps } from '@kbn/shared-ux-components'; import { PrimaryNavigationProps } from './types'; import { usePrimaryNavigationItems } from './use_navigation_items'; -import { SolutionGroupedNav } from '../solution_grouped_nav'; -import { useNavItems } from '../solution_grouped_nav/solution_grouped_nav_item'; import { useIsGroupedNavigationEnabled } from '../helpers'; +import { SecuritySideNav } from '../security_side_nav'; const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', { defaultMessage: 'Security', @@ -48,7 +47,6 @@ export const usePrimaryNavigation = ({ // we do need navTabs in case the selectedTabId appears after initial load (ex. checking permissions for anomalies) }, [pageName, navTabs, mapLocationToTab, selectedTabId]); - const navLinkItems = useNavItems(); const navItems = usePrimaryNavigationItems({ navTabs, selectedTabId, @@ -65,7 +63,7 @@ export const usePrimaryNavigation = ({ icon: 'logoSecurity', ...(isGroupedNavigationEnabled ? { - children: , + children: , closeFlyoutButtonPosition: 'inside', } : { items: navItems }), diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts index 1a78444012334a..45a7ed373222f4 100644 --- a/x-pack/plugins/security_solution/public/common/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -4,48 +4,30 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { i18n } from '@kbn/i18n'; -import { SecurityPageName, THREAT_HUNTING_PATH } from '../../../common/constants'; -import { THREAT_HUNTING } from '../../app/translations'; -import { FEATURE, LinkItem, UserPermissions } from './types'; -import { links as hostsLinks } from '../../hosts/links'; +import { CoreStart } from '@kbn/core/public'; +import { AppLinkItems } from './types'; import { links as detectionLinks } from '../../detections/links'; -import { links as networkLinks } from '../../network/links'; -import { links as usersLinks } from '../../users/links'; import { links as timelinesLinks } from '../../timelines/links'; import { getCasesLinkItems } from '../../cases/links'; -import { links as managementLinks } from '../../management/links'; -import { gettingStartedLinks, dashboardsLandingLinks } from '../../overview/links'; +import { getManagementLinkItems } from '../../management/links'; +import { dashboardsLandingLinks, threatHuntingLandingLinks } from '../../landing_pages/links'; +import { gettingStartedLinks } from '../../overview/links'; +import { StartPlugins } from '../../types'; -export const appLinks: Readonly = Object.freeze([ - gettingStartedLinks, - dashboardsLandingLinks, - detectionLinks, - { - id: SecurityPageName.threatHuntingLanding, - title: THREAT_HUNTING, - path: THREAT_HUNTING_PATH, - globalNavEnabled: false, - features: [FEATURE.general], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.threatHunting', { - defaultMessage: 'Threat hunting', - }), - ], - links: [hostsLinks, networkLinks, usersLinks], - skipUrlState: true, - hideTimeline: true, - }, - timelinesLinks, - getCasesLinkItems(), - managementLinks, -]); +export const getAppLinks = async ( + core: CoreStart, + plugins: StartPlugins +): Promise => { + const managementLinks = await getManagementLinkItems(core, plugins); + const casesLinks = getCasesLinkItems(); -export const getAppLinks = async ({ - enableExperimental, - license, - capabilities, -}: UserPermissions) => { - // OLM team, implement async behavior here - return appLinks; + return Object.freeze([ + dashboardsLandingLinks, + detectionLinks, + timelinesLinks, + casesLinks, + threatHuntingLandingLinks, + gettingStartedLinks, + managementLinks, + ]); }; diff --git a/x-pack/plugins/security_solution/public/common/links/index.tsx b/x-pack/plugins/security_solution/public/common/links/index.tsx index 6d8e99cd416d2f..e4e4de0b49430e 100644 --- a/x-pack/plugins/security_solution/public/common/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/links/index.tsx @@ -6,3 +6,4 @@ */ export * from './links'; +export * from './types'; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts index b68ae3d863de32..896f9357077c80 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -5,399 +5,223 @@ * 2.0. */ +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { Capabilities } from '@kbn/core/types'; +import { mockGlobalState, TestProviders } from '../mock'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; +import { AppLinkItems } from './types'; +import { act, renderHook } from '@testing-library/react-hooks'; import { + useAppLinks, getAncestorLinksInfo, - getDeepLinks, - getInitialDeepLinks, getLinkInfo, - getNavLinkItems, needsUrlState, + updateAppLinks, + excludeAppLink, } from './links'; -import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; -import { Capabilities } from '@kbn/core/types'; -import { AppDeepLink } from '@kbn/core/public'; -import { mockGlobalState } from '../mock'; -import { NavLinkItem } from './types'; -import { LicenseType } from '@kbn/licensing-plugin/common/types'; -import { LicenseService } from '../../../common/license'; + +const defaultAppLinks: AppLinkItems = [ + { + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: 'Authentications', + path: `/hosts/authentications`, + }, + { + id: SecurityPageName.hostsEvents, + title: 'Events', + path: `/hosts/events`, + skipUrlState: true, + }, + ], + }, +]; const mockExperimentalDefaults = mockGlobalState.app.enableExperimental; + const mockCapabilities = { [CASES_FEATURE_ID]: { read_cases: true, crud_cases: true }, [SERVER_APP_ID]: { show: true }, } as unknown as Capabilities; -const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null => - deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => { - if (deepLinkFound !== null) { - return deepLinkFound; - } - if (deepLink.id === id) { - return deepLink; - } - if (deepLink.deepLinks) { - return findDeepLink(id, deepLink.deepLinks); - } - return null; - }, null); - -const findNavLink = (id: SecurityPageName, navLinks: NavLinkItem[]): NavLinkItem | null => - navLinks.reduce((deepLinkFound: NavLinkItem | null, deepLink) => { - if (deepLinkFound !== null) { - return deepLinkFound; - } - if (deepLink.id === id) { - return deepLink; - } - if (deepLink.links) { - return findNavLink(id, deepLink.links); - } - return null; - }, null); - -// remove filter once new nav is live -const allPages = Object.values(SecurityPageName).filter( - (pageName) => - pageName !== SecurityPageName.explore && - pageName !== SecurityPageName.detections && - pageName !== SecurityPageName.investigate -); -const casesPages = [ - SecurityPageName.case, - SecurityPageName.caseConfigure, - SecurityPageName.caseCreate, -]; -const featureFlagPages = [ - SecurityPageName.detectionAndResponse, - SecurityPageName.hostsAuthentications, - SecurityPageName.hostsRisk, - SecurityPageName.usersRisk, -]; -const premiumPages = [ - SecurityPageName.caseConfigure, - SecurityPageName.hostsAnomalies, - SecurityPageName.networkAnomalies, - SecurityPageName.usersAnomalies, - SecurityPageName.detectionAndResponse, - SecurityPageName.hostsRisk, - SecurityPageName.usersRisk, -]; -const nonCasesPages = allPages.reduce( - (acc: SecurityPageName[], p) => - casesPages.includes(p) || featureFlagPages.includes(p) ? acc : [p, ...acc], - [] -); - const licenseBasicMock = jest.fn().mockImplementation((arg: LicenseType) => arg === 'basic'); const licensePremiumMock = jest.fn().mockReturnValue(true); const mockLicense = { - isAtLeast: licensePremiumMock, -} as unknown as LicenseService; - -const threatHuntingLinkInfo = { - features: ['siem.show'], - globalNavEnabled: false, - globalSearchKeywords: ['Threat hunting'], - id: 'threat_hunting', - path: '/threat_hunting', - title: 'Threat Hunting', - hideTimeline: true, - skipUrlState: true, -}; + hasAtLeast: licensePremiumMock, +} as unknown as ILicense; -const hostsLinkInfo = { - globalNavEnabled: true, - globalNavOrder: 9002, - globalSearchEnabled: true, - globalSearchKeywords: ['Hosts'], - id: 'hosts', - path: '/hosts', - title: 'Hosts', - landingImage: 'test-file-stub', - description: 'A comprehensive overview of all hosts and host-related security events.', -}; +const renderUseAppLinks = () => + renderHook<{}, AppLinkItems>(() => useAppLinks(), { wrapper: TestProviders }); -describe('security app link helpers', () => { +describe('Security app links', () => { beforeEach(() => { - mockLicense.isAtLeast = licensePremiumMock; - }); - describe('getInitialDeepLinks', () => { - it('should return all pages in the app', () => { - const links = getInitialDeepLinks(); - allPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - }); - }); - describe('getDeepLinks', () => { - it('basicLicense should return only basic links', async () => { - mockLicense.isAtLeast = licenseBasicMock; + mockLicense.hasAtLeast = licensePremiumMock; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); - allPages.forEach((page) => { - if (premiumPages.includes(page)) { - return expect(findDeepLink(page, links)).toBeFalsy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findDeepLink(page, links)).toBeTruthy(); - }); - }); - it('platinumLicense should return all links', async () => { - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - allPages.forEach((page) => { - if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { - return expect(findDeepLink(page, links)).toBeTruthy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findDeepLink(page, links)).toBeTruthy(); - }); - }); - it('hideWhenExperimentalKey hides entry when key = true', async () => { - const links = await getDeepLinks({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); - }); - it('hideWhenExperimentalKey shows entry when key = false', async () => { - const links = await getDeepLinks({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); - }); - it('experimentalKey shows entry when key = false', async () => { - const links = await getDeepLinks({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: false, - riskyUsersEnabled: false, - detectionResponseEnabled: false, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); - expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeFalsy(); - expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); - }); - it('experimentalKey shows entry when key = true', async () => { - const links = await getDeepLinks({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: true, - riskyUsersEnabled: true, - detectionResponseEnabled: true, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); - expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeTruthy(); - expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + updateAppLinks(defaultAppLinks, { + capabilities: mockCapabilities, + experimentalFeatures: mockExperimentalDefaults, + license: mockLicense, }); + }); - it('Removes siem features when siem capabilities are false', async () => { - const capabilities = { - ...mockCapabilities, - [SERVER_APP_ID]: { show: false }, - } as unknown as Capabilities; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => { - // investigate is active for both Cases and Timelines pages - if (page === SecurityPageName.investigate) { - return expect(findDeepLink(page, links)).toBeTruthy(); - } - return expect(findDeepLink(page, links)).toBeFalsy(); - }); - casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - }); - it('Removes cases features when cases capabilities are false', async () => { - const capabilities = { - ...mockCapabilities, - [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, - } as unknown as Capabilities; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeFalsy()); + describe('useAppLinks', () => { + it('should return initial appLinks', () => { + const { result } = renderUseAppLinks(); + expect(result.current).toStrictEqual(defaultAppLinks); + }); + + it('should filter not allowed links', async () => { + const { result, waitForNextUpdate } = renderUseAppLinks(); + // this link should not be excluded, the test checks all conditions are passed + const networkLinkItem = { + id: SecurityPageName.network, + title: 'Network', + path: '/network', + capabilities: [`${CASES_FEATURE_ID}.read_cases`, `${SERVER_APP_ID}.show`], + experimentalKey: 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults, + hideWhenExperimentalKey: 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults, + licenseType: 'basic' as const, + }; + + await act(async () => { + updateAppLinks( + [ + { + ...networkLinkItem, + // all its links should be filtered for all different criteria + links: [ + { + id: SecurityPageName.networkExternalAlerts, + title: 'external alerts', + path: '/external_alerts', + experimentalKey: + 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults, + }, + { + id: SecurityPageName.networkDns, + title: 'dns', + path: '/dns', + hideWhenExperimentalKey: + 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults, + }, + { + id: SecurityPageName.networkAnomalies, + title: 'Anomalies', + path: '/anomalies', + capabilities: [ + `${CASES_FEATURE_ID}.read_cases`, + `${CASES_FEATURE_ID}.write_cases`, + ], + }, + { + id: SecurityPageName.networkHttp, + title: 'Http', + path: '/http', + licenseType: 'gold', + }, + ], + }, + { + // should be excluded by license with all its links + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + licenseType: 'platinum', + links: [ + { + id: SecurityPageName.hostsEvents, + title: 'Events', + path: '/events', + }, + ], + }, + ], + { + capabilities: { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + }, + experimentalFeatures: { + flagEnabled: true, + flagDisabled: false, + } as unknown as typeof mockExperimentalDefaults, + license: { hasAtLeast: licenseBasicMock } as unknown as ILicense, + } + ); + await waitForNextUpdate(); + }); + + expect(result.current).toStrictEqual([networkLinkItem]); }); }); - describe('getNavLinkItems', () => { - it('basicLicense should return only basic links', () => { - mockLicense.isAtLeast = licenseBasicMock; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); - allPages.forEach((page) => { - if (premiumPages.includes(page)) { - return expect(findNavLink(page, links)).toBeFalsy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findNavLink(page, links)).toBeTruthy(); - }); - }); - it('platinumLicense should return all links', () => { - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - allPages.forEach((page) => { - if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { - return expect(findNavLink(page, links)).toBeTruthy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findNavLink(page, links)).toBeTruthy(); - }); - }); - it('hideWhenExperimentalKey hides entry when key = true', () => { - const links = getNavLinkItems({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, - license: mockLicense, - capabilities: mockCapabilities, + describe('excludeAppLink', () => { + it('should exclude link from app links', async () => { + const { result, waitForNextUpdate } = renderUseAppLinks(); + await act(async () => { + excludeAppLink(SecurityPageName.hostsEvents); + await waitForNextUpdate(); }); - expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); - }); - it('hideWhenExperimentalKey shows entry when key = false', () => { - const links = getNavLinkItems({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); - }); - it('experimentalKey shows entry when key = false', () => { - const links = getNavLinkItems({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: false, - riskyUsersEnabled: false, - detectionResponseEnabled: false, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); - expect(findNavLink(SecurityPageName.usersRisk, links)).toBeFalsy(); - expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); - }); - it('experimentalKey shows entry when key = true', () => { - const links = getNavLinkItems({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: true, - riskyUsersEnabled: true, - detectionResponseEnabled: true, + expect(result.current).toStrictEqual([ + { + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: 'Authentications', + path: `/hosts/authentications`, + }, + ], }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); - expect(findNavLink(SecurityPageName.usersRisk, links)).toBeTruthy(); - expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); - }); - - it('Removes siem features when siem capabilities are false', () => { - const capabilities = { - ...mockCapabilities, - [SERVER_APP_ID]: { show: false }, - } as unknown as Capabilities; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => { - // investigate is active for both Cases and Timelines pages - if (page === SecurityPageName.investigate) { - return expect(findNavLink(page, links)).toBeTruthy(); - } - return expect(findNavLink(page, links)).toBeFalsy(); - }); - casesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); - }); - it('Removes cases features when cases capabilities are false', () => { - const capabilities = { - ...mockCapabilities, - [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, - } as unknown as Capabilities; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); - casesPages.forEach((page) => expect(findNavLink(page, links)).toBeFalsy()); + ]); }); }); describe('getAncestorLinksInfo', () => { - it('finds flattened links for hosts', () => { - const hierarchy = getAncestorLinksInfo(SecurityPageName.hosts); - expect(hierarchy).toEqual([threatHuntingLinkInfo, hostsLinkInfo]); - }); - it('finds flattened links for uncommonProcesses', () => { - const hierarchy = getAncestorLinksInfo(SecurityPageName.uncommonProcesses); - expect(hierarchy).toEqual([ - threatHuntingLinkInfo, - hostsLinkInfo, + it('should find ancestors flattened links', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.hostsEvents); + expect(hierarchy).toStrictEqual([ { - id: 'uncommon_processes', - path: '/hosts/uncommonProcesses', - title: 'Uncommon Processes', + id: SecurityPageName.hosts, + path: '/hosts', + title: 'Hosts', + }, + { + id: SecurityPageName.hostsEvents, + path: '/hosts/events', + skipUrlState: true, + title: 'Events', }, ]); }); }); describe('needsUrlState', () => { - it('returns true when url state exists for page', () => { + it('should return true when url state exists for page', () => { const needsUrl = needsUrlState(SecurityPageName.hosts); expect(needsUrl).toEqual(true); }); - it('returns false when url state does not exist for page', () => { - const needsUrl = needsUrlState(SecurityPageName.landing); + it('should return false when url state does not exist for page', () => { + const needsUrl = needsUrlState(SecurityPageName.hostsEvents); expect(needsUrl).toEqual(false); }); }); describe('getLinkInfo', () => { - it('gets information for an individual link', () => { - const linkInfo = getLinkInfo(SecurityPageName.hosts); - expect(linkInfo).toEqual(hostsLinkInfo); + it('should get information for an individual link', () => { + const linkInfo = getLinkInfo(SecurityPageName.hostsEvents); + expect(linkInfo).toStrictEqual({ + id: SecurityPageName.hostsEvents, + path: '/hosts/events', + skipUrlState: true, + title: 'Events', + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index 57965bdeba0c06..384861a9dc5e75 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -5,169 +5,120 @@ * 2.0. */ -import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import type { Capabilities } from '@kbn/core/public'; import { get } from 'lodash'; +import { useEffect, useState } from 'react'; +import { BehaviorSubject } from 'rxjs'; import { SecurityPageName } from '../../../common/constants'; -import { appLinks, getAppLinks } from './app_links'; -import { - Feature, +import type { + AppLinkItems, LinkInfo, LinkItem, - NavLinkItem, NormalizedLink, NormalizedLinks, - UserPermissions, + LinksPermissions, } from './types'; -const createDeepLink = (link: LinkItem, linkProps?: UserPermissions): AppDeepLink => ({ - id: link.id, - path: link.path, - title: link.title, - ...(link.links && link.links.length - ? { - deepLinks: reduceLinks({ - links: link.links, - linkProps, - formatFunction: createDeepLink, - }), - } - : {}), - ...(link.globalSearchKeywords != null ? { keywords: link.globalSearchKeywords } : {}), - ...(link.globalNavEnabled != null - ? { navLinkStatus: link.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden } - : {}), - ...(link.globalNavOrder != null ? { order: link.globalNavOrder } : {}), - ...(link.globalSearchEnabled != null ? { searchable: link.globalSearchEnabled } : {}), +/** + * App links updater, it keeps the value of the app links in sync with all application. + * It can be updated using `updateAppLinks` or `excludeAppLink` + * Read it using `subscribeAppLinks` or `useAppLinks` hook. + */ +const appLinksUpdater$ = new BehaviorSubject<{ + links: AppLinkItems; + normalizedLinks: NormalizedLinks; +}>({ + links: [], // stores the appLinkItems recursive hierarchy + normalizedLinks: {}, // stores a flatten normalized object for direct id access }); -const createNavLinkItem = (link: LinkItem, linkProps?: UserPermissions): NavLinkItem => ({ - id: link.id, - path: link.path, - title: link.title, - ...(link.description != null ? { description: link.description } : {}), - ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), - ...(link.landingImage != null ? { image: link.landingImage } : {}), - ...(link.links && link.links.length - ? { - links: reduceLinks({ - links: link.links, - linkProps, - formatFunction: createNavLinkItem, - }), - } - : {}), - ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), -}); +const getAppLinksValue = (): AppLinkItems => appLinksUpdater$.getValue().links; +const getNormalizedLinksValue = (): NormalizedLinks => appLinksUpdater$.getValue().normalizedLinks; -const hasFeaturesCapability = ( - features: Feature[] | undefined, - capabilities: Capabilities -): boolean => { - if (!features) { - return true; - } - return features.some((featureKey) => get(capabilities, featureKey, false)); -}; +/** + * Subscribes to the updater to get the app links updates + */ +export const subscribeAppLinks = (onChange: (links: AppLinkItems) => void) => + appLinksUpdater$.subscribe(({ links }) => onChange(links)); -const isLinkAllowed = (link: LinkItem, linkProps?: UserPermissions) => - !( - linkProps != null && - // exclude link when license is basic and link is premium - ((linkProps.license && !linkProps.license.isAtLeast(link.licenseType ?? 'basic')) || - // exclude link when enableExperimental[hideWhenExperimentalKey] is enabled and link has hideWhenExperimentalKey - (link.hideWhenExperimentalKey != null && - linkProps.enableExperimental[link.hideWhenExperimentalKey]) || - // exclude link when enableExperimental[experimentalKey] is disabled and link has experimentalKey - (link.experimentalKey != null && !linkProps.enableExperimental[link.experimentalKey]) || - // exclude link when link is not part of enabled feature capabilities - (linkProps.capabilities != null && - !hasFeaturesCapability(link.features, linkProps.capabilities))) - ); - -export function reduceLinks({ - links, - linkProps, - formatFunction, -}: { - links: Readonly; - linkProps?: UserPermissions; - formatFunction: (link: LinkItem, linkProps?: UserPermissions) => T; -}): T[] { - return links.reduce( - (deepLinks: T[], link: LinkItem) => - isLinkAllowed(link, linkProps) ? [...deepLinks, formatFunction(link, linkProps)] : deepLinks, - [] - ); -} - -export const getInitialDeepLinks = (): AppDeepLink[] => { - return appLinks.map((link) => createDeepLink(link)); -}; +/** + * Hook to get the app links updated value + */ +export const useAppLinks = (): AppLinkItems => { + const [appLinks, setAppLinks] = useState(getAppLinksValue); -export const getDeepLinks = async ({ - enableExperimental, - license, - capabilities, -}: UserPermissions): Promise => { - const links = await getAppLinks({ enableExperimental, license, capabilities }); - return reduceLinks({ - links, - linkProps: { enableExperimental, license, capabilities }, - formatFunction: createDeepLink, - }); -}; + useEffect(() => { + const linksSubscription = subscribeAppLinks((newAppLinks) => { + setAppLinks(newAppLinks); + }); + return () => linksSubscription.unsubscribe(); + }, []); -export const getNavLinkItems = ({ - enableExperimental, - license, - capabilities, -}: UserPermissions): NavLinkItem[] => - reduceLinks({ - links: appLinks, - linkProps: { enableExperimental, license, capabilities }, - formatFunction: createNavLinkItem, - }); + return appLinks; +}; /** - * Recursive function to create the `NormalizedLinks` structure from a `LinkItem` array parameter + * Updates the app links applying the filter by permissions */ -const getNormalizedLinks = ( - currentLinks: Readonly, - parentId?: SecurityPageName -): NormalizedLinks => { - const result = currentLinks.reduce>( - (normalized, { links, ...currentLink }) => { - normalized[currentLink.id] = { - ...currentLink, - parentId, - }; - if (links && links.length > 0) { - Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); - } - return normalized; - }, - {} - ); - return result as NormalizedLinks; +export const updateAppLinks = ( + appLinksToUpdate: AppLinkItems, + linksPermissions: LinksPermissions +) => { + const filteredAppLinks = getFilteredAppLinks(appLinksToUpdate, linksPermissions); + appLinksUpdater$.next({ + links: Object.freeze(filteredAppLinks), + normalizedLinks: Object.freeze(getNormalizedLinks(filteredAppLinks)), + }); }; /** - * Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children - */ -const normalizedLinks: Readonly = Object.freeze(getNormalizedLinks(appLinks)); -/** - * Returns the `NormalizedLink` from a link id parameter. - * The object reference is frozen to make sure it is not mutated by the caller. + * Excludes a link by id from the current app links + * @deprecated this function will not be needed when async link filtering is migrated to the main getAppLinks functions */ -const getNormalizedLink = (id: SecurityPageName): Readonly => - Object.freeze(normalizedLinks[id]); +export const excludeAppLink = (linkId: SecurityPageName) => { + const { links, normalizedLinks } = appLinksUpdater$.getValue(); + if (!normalizedLinks[linkId]) { + return; + } + + let found = false; + const excludeRec = (currentLinks: AppLinkItems): LinkItem[] => + currentLinks.reduce((acc, link) => { + if (!found) { + if (link.id === linkId) { + found = true; + return acc; + } + if (link.links) { + const excludedLinks = excludeRec(link.links); + if (excludedLinks.length > 0) { + acc.push({ ...link, links: excludedLinks }); + return acc; + } + } + } + acc.push(link); + return acc; + }, []); + + const excludedLinks = excludeRec(links); + + appLinksUpdater$.next({ + links: Object.freeze(excludedLinks), + normalizedLinks: Object.freeze(getNormalizedLinks(excludedLinks)), + }); +}; /** * Returns the `LinkInfo` from a link id parameter */ -export const getLinkInfo = (id: SecurityPageName): LinkInfo => { +export const getLinkInfo = (id: SecurityPageName): LinkInfo | undefined => { + const normalizedLink = getNormalizedLink(id); + if (!normalizedLink) { + return undefined; + } // discards the parentId and creates the linkInfo copy. - const { parentId, ...linkInfo } = getNormalizedLink(id); + const { parentId, ...linkInfo } = normalizedLink; return linkInfo; }; @@ -178,9 +129,14 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { const ancestors: LinkInfo[] = []; let currentId: SecurityPageName | undefined = id; while (currentId) { - const { parentId, ...linkInfo } = getNormalizedLink(currentId); - ancestors.push(linkInfo); - currentId = parentId; + const normalizedLink = getNormalizedLink(currentId); + if (normalizedLink) { + const { parentId, ...linkInfo } = normalizedLink; + ancestors.push(linkInfo); + currentId = parentId; + } else { + currentId = undefined; + } } return ancestors.reverse(); }; @@ -190,9 +146,82 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { * Defaults to `true` if the `skipUrlState` property of the `LinkItem` is `undefined`. */ export const needsUrlState = (id: SecurityPageName): boolean => { - return !getNormalizedLink(id).skipUrlState; + return !getNormalizedLink(id)?.skipUrlState; +}; + +// Internal functions + +/** + * Creates the `NormalizedLinks` structure from a `LinkItem` array + */ +const getNormalizedLinks = ( + currentLinks: AppLinkItems, + parentId?: SecurityPageName +): NormalizedLinks => { + return currentLinks.reduce((normalized, { links, ...currentLink }) => { + normalized[currentLink.id] = { + ...currentLink, + parentId, + }; + if (links && links.length > 0) { + Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); + } + return normalized; + }, {}); +}; + +const getNormalizedLink = (id: SecurityPageName): Readonly | undefined => + getNormalizedLinksValue()[id]; + +const getFilteredAppLinks = ( + appLinkToFilter: AppLinkItems, + linksPermissions: LinksPermissions +): LinkItem[] => + appLinkToFilter.reduce((acc, { links, ...appLink }) => { + if (!isLinkAllowed(appLink, linksPermissions)) { + return acc; + } + if (links) { + const childrenLinks = getFilteredAppLinks(links, linksPermissions); + if (childrenLinks.length > 0) { + acc.push({ ...appLink, links: childrenLinks }); + } else { + acc.push(appLink); + } + } else { + acc.push(appLink); + } + return acc; + }, []); + +// It checks if the user has at least one of the link capabilities needed +const hasCapabilities = (linkCapabilities: string[], userCapabilities: Capabilities): boolean => + linkCapabilities.some((linkCapability) => get(userCapabilities, linkCapability, false)); + +const isLinkAllowed = ( + link: LinkItem, + { license, experimentalFeatures, capabilities }: LinksPermissions +) => { + const linkLicenseType = link.licenseType ?? 'basic'; + if (license) { + if (!license.hasAtLeast(linkLicenseType)) { + return false; + } + } else if (linkLicenseType !== 'basic') { + return false; + } + if (link.hideWhenExperimentalKey && experimentalFeatures[link.hideWhenExperimentalKey]) { + return false; + } + if (link.experimentalKey && !experimentalFeatures[link.experimentalKey]) { + return false; + } + if (link.capabilities && !hasCapabilities(link.capabilities, capabilities)) { + return false; + } + return true; }; export const getLinksWithHiddenTimeline = (): LinkInfo[] => { - return Object.values(normalizedLinks).filter((link) => link.hideTimeline); + return Object.values(getNormalizedLinksValue()).filter((link) => link.hideTimeline); }; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index bfa87851306ff4..323873cafc23c7 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -6,43 +6,73 @@ */ import { Capabilities } from '@kbn/core/types'; -import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; import { IconType } from '@elastic/eui'; -import { LicenseService } from '../../../common/license'; import { ExperimentalFeatures } from '../../../common/experimental_features'; -import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { SecurityPageName } from '../../../common/constants'; -export const FEATURE = { - general: `${SERVER_APP_ID}.show`, - casesRead: `${CASES_FEATURE_ID}.read_cases`, - casesCrud: `${CASES_FEATURE_ID}.crud_cases`, -}; - -export type Feature = Readonly; +/** + * Permissions related parameters needed for the links to be filtered + */ +export interface LinksPermissions { + capabilities: Capabilities; + experimentalFeatures: Readonly; + license?: ILicense; +} -export interface UserPermissions { - enableExperimental: ExperimentalFeatures; - license?: LicenseService; - capabilities?: Capabilities; +export interface LinkCategory { + label: string; + linkIds: readonly SecurityPageName[]; } +export type LinkCategories = Readonly; + export interface LinkItem { + /** + * The description of the link content + */ description?: string; - disabled?: boolean; // default false /** - * Displays deep link when feature flag is enabled. + * Experimental flag needed to enable the link */ experimentalKey?: keyof ExperimentalFeatures; - features?: Feature[]; /** - * Hides deep link when feature flag is enabled. + * Capabilities strings (using object dot notation) to enable the link. + * Uses "or" conditional, only one enabled capability is needed to activate the link + */ + capabilities?: string[]; + /** + * Categories to display in the navigation + */ + categories?: LinkCategories; + /** + * Enables link in the global navigation. Defaults to false. + */ + globalNavEnabled?: boolean; + /** + * Global navigation order number */ - globalNavEnabled?: boolean; // default false globalNavOrder?: number; - globalSearchEnabled?: boolean; + /** + * Disables link in the global search. Defaults to false. + */ + globalSearchDisabled?: boolean; + /** + * Keywords for the global search to search. + */ globalSearchKeywords?: string[]; + /** + * Experimental flag needed to disable the link. Opposite of experimentalKey + */ hideWhenExperimentalKey?: keyof ExperimentalFeatures; + /** + * Link id. Refers to a SecurityPageName + */ id: SecurityPageName; + /** + * Displays the "Beta" badge + */ + isBeta?: boolean; /** * Icon that is displayed on menu navigation landing page. * Only required for pages that are displayed inside a landing page. @@ -53,26 +83,38 @@ export interface LinkItem { * Only required for pages that are displayed inside a landing page. */ landingImage?: string; - isBeta?: boolean; + /** + * Minimum license required to enable the link + */ licenseType?: LicenseType; + /** + * Nested links + */ links?: LinkItem[]; + /** + * Link path relative to security root + */ path: string; - skipUrlState?: boolean; // defaults to false + /** + * Disables link in the side navigation. Defaults to false. + */ + sideNavDisabled?: boolean; + /** + * Disables the state query string in the URL. Defaults to false. + */ + skipUrlState?: boolean; + /** + * Disables the timeline call to action on the bottom of the page. Defaults to false. + */ hideTimeline?: boolean; // defaults to false + /** + * Title of the link + */ title: string; } -export interface NavLinkItem { - description?: string; - icon?: IconType; - id: SecurityPageName; - links?: NavLinkItem[]; - image?: string; - path: string; - title: string; - skipUrlState?: boolean; // default to false -} +export type AppLinkItems = Readonly; export type LinkInfo = Omit; export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; -export type NormalizedLinks = Record; +export type NormalizedLinks = Partial>; diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index 33a9f3a37a42f9..ca9029c6c0939c 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -6,7 +6,12 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; +import { coreMock } from '@kbn/core/public/mocks'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; +import { updateAppLinks } from '../../links'; +import { getAppLinks } from '../../links/app_links'; import { useShowTimeline } from './use_show_timeline'; +import { StartPlugins } from '../../../types'; const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/overview' }); jest.mock('react-router-dom', () => { @@ -24,6 +29,23 @@ jest.mock('../../components/navigation/helpers', () => ({ })); describe('use show timeline', () => { + beforeAll(async () => { + // initialize all App links before running test + const appLinks = await getAppLinks(coreMock.createStart(), {} as StartPlugins); + updateAppLinks(appLinks, { + experimentalFeatures: allowedExperimentalValues, + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + siem: { + show: true, + crud: true, + }, + }, + }); + }); describe('useIsGroupedNavigationEnabled false', () => { beforeAll(() => { mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts index 8c1737a4519a72..8a23cbf9e4318e 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts @@ -38,6 +38,9 @@ export const savedRuleMock: Rule = { max_signals: 100, query: "user.email: 'root@elastic.co'", references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], tags: ['APM'], @@ -80,6 +83,9 @@ export const rulesMock: FetchRulesResponse = { 'event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection', filters: [], references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', @@ -115,6 +121,9 @@ export const rulesMock: FetchRulesResponse = { query: 'event.kind:alert and event.module:endgame and event.action:rules_engine_event', filters: [], references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'medium', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index ddd65674274be4..d6e278599d62d2 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -34,6 +34,9 @@ import { BulkAction, BulkActionEditPayload, ruleExecutionSummary, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../../../../../common/detection_engine/schemas/common'; import { @@ -102,11 +105,14 @@ export const RuleSchema = t.intersection([ name: t.string, max_signals: t.number, references: t.array(t.string), + related_integrations: RelatedIntegrationArray, + required_fields: RequiredFieldArray, risk_score: t.number, risk_score_mapping, rule_id: t.string, severity, severity_mapping, + setup: SetupGuide, tags: t.array(t.string), type, to: t.string, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx index 096463872fc011..3ca18552a85ef3 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx @@ -67,9 +67,12 @@ describe('useRule', () => { max_signals: 100, query: "user.email: 'root@elastic.co'", references: [], + related_integrations: [], + required_fields: [], risk_score: 75, risk_score_mapping: [], rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', + setup: '', severity: 'high', severity_mapping: [], tags: ['APM'], diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx index d7c4ad8772bd25..1816fd4c5a7af5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx @@ -78,9 +78,12 @@ describe('useRuleWithFallback', () => { "name": "Test rule", "query": "user.email: 'root@elastic.co'", "references": Array [], + "related_integrations": Array [], + "required_fields": Array [], "risk_score": 75, "risk_score_mapping": Array [], "rule_id": "bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf", + "setup": "", "severity": "high", "severity_mapping": Array [], "tags": Array [ diff --git a/x-pack/plugins/security_solution/public/detections/links.ts b/x-pack/plugins/security_solution/public/detections/links.ts index 1cfac62d80e6e9..df9d32fcb57ed1 100644 --- a/x-pack/plugins/security_solution/public/detections/links.ts +++ b/x-pack/plugins/security_solution/public/detections/links.ts @@ -5,21 +5,20 @@ * 2.0. */ import { i18n } from '@kbn/i18n'; -import { ALERTS_PATH, SecurityPageName } from '../../common/constants'; +import { ALERTS_PATH, SecurityPageName, SERVER_APP_ID } from '../../common/constants'; import { ALERTS } from '../app/translations'; -import { LinkItem, FEATURE } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; export const links: LinkItem = { id: SecurityPageName.alerts, title: ALERTS, path: ALERTS_PATH, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalNavEnabled: true, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.alerts', { defaultMessage: 'Alerts', }), ], - globalSearchEnabled: true, globalNavOrder: 9001, }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 77de8902be33a6..d9f16242a544af 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -70,6 +70,9 @@ export const mockRule = (id: string): Rule => ({ timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', timeline_title: 'Untitled timeline', meta: { from: '0m' }, + related_integrations: [], + required_fields: [], + setup: '', severity: 'low', severity_mapping: [], updated_by: 'elastic', @@ -133,6 +136,9 @@ export const mockRuleWithEverything = (id: string): Rule => ({ timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', timeline_title: 'Titled timeline', meta: { from: '0m' }, + related_integrations: [], + required_fields: [], + setup: '', severity: 'low', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/plugins/security_solution/public/hosts/links.ts b/x-pack/plugins/security_solution/public/hosts/links.ts index d1bc26c5fb3f2f..dcdeb73ac12195 100644 --- a/x-pack/plugins/security_solution/public/hosts/links.ts +++ b/x-pack/plugins/security_solution/public/hosts/links.ts @@ -24,7 +24,6 @@ export const links: LinkItem = { defaultMessage: 'Hosts', }), ], - globalSearchEnabled: true, globalNavOrder: 9002, links: [ { diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx index 81b72527500adf..57aee98af4e9d1 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { TestProviders } from '../../common/mock'; import { LandingLinksIcons } from './landing_links_icons'; @@ -17,7 +17,6 @@ const DEFAULT_NAV_ITEM: NavLinkItem = { title: 'TEST LABEL', description: 'TEST DESCRIPTION', icon: 'myTestIcon', - path: '', }; const mockNavigateTo = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx index 04a3e20b1f1789..b30d4f404b1637 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx @@ -12,7 +12,7 @@ import { SecuritySolutionLinkAnchor, withSecuritySolutionLink, } from '../../common/components/links'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; interface LandingLinksImagesProps { items: NavLinkItem[]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx index c44374852f29bf..81881a3796f0be 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { TestProviders } from '../../common/mock'; import { LandingLinksImages } from './landing_links_images'; @@ -17,7 +17,6 @@ const DEFAULT_NAV_ITEM: NavLinkItem = { title: 'TEST LABEL', description: 'TEST DESCRIPTION', image: 'TEST_IMAGE.png', - path: '', }; jest.mock('../../common/lib/kibana/kibana_react', () => { diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx index 22bcc0f1aa2516..4cf8db26bbe7a1 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiPanel, EuiText, EuiTitle } from import React from 'react'; import styled from 'styled-components'; import { withSecuritySolutionLink } from '../../common/components/links'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; interface LandingLinksImagesProps { items: NavLinkItem[]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/constants.ts b/x-pack/plugins/security_solution/public/landing_pages/constants.ts deleted file mode 100644 index a6b72a5e7db4f9..00000000000000 --- a/x-pack/plugins/security_solution/public/landing_pages/constants.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { SecurityPageName } from '../app/types'; - -export interface LandingNavGroup { - label: string; - itemIds: SecurityPageName[]; -} - -export const MANAGE_NAVIGATION_CATEGORIES: LandingNavGroup[] = [ - { - label: i18n.translate('xpack.securitySolution.landing.siemTitle', { - defaultMessage: 'SIEM', - }), - itemIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.landing.endpointsTitle', { - defaultMessage: 'ENDPOINTS', - }), - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, -]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/links.ts b/x-pack/plugins/security_solution/public/landing_pages/links.ts new file mode 100644 index 00000000000000..48cd31485ea7fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/landing_pages/links.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + DASHBOARDS_PATH, + SecurityPageName, + SERVER_APP_ID, + THREAT_HUNTING_PATH, +} from '../../common/constants'; +import { DASHBOARDS, THREAT_HUNTING } from '../app/translations'; +import { LinkItem } from '../common/links/types'; +import { overviewLinks, detectionResponseLinks } from '../overview/links'; +import { links as hostsLinks } from '../hosts/links'; +import { links as networkLinks } from '../network/links'; +import { links as usersLinks } from '../users/links'; + +export const dashboardsLandingLinks: LinkItem = { + id: SecurityPageName.dashboardsLanding, + title: DASHBOARDS, + path: DASHBOARDS_PATH, + globalNavEnabled: false, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.dashboards', { + defaultMessage: 'Dashboards', + }), + ], + links: [overviewLinks, detectionResponseLinks], + skipUrlState: true, + hideTimeline: true, +}; + +export const threatHuntingLandingLinks: LinkItem = { + id: SecurityPageName.threatHuntingLanding, + title: THREAT_HUNTING, + path: THREAT_HUNTING_PATH, + globalNavEnabled: false, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.threatHunting', { + defaultMessage: 'Threat hunting', + }), + ], + links: [hostsLinks, networkLinks, usersLinks], + skipUrlState: true, + hideTimeline: true, +}; diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx index 1955d56c0a151a..a09db6ebf5eaa9 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx @@ -9,53 +9,58 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; import { TestProviders } from '../../common/mock'; -import { LandingCategories } from './manage'; -import { NavLinkItem } from '../../common/links/types'; +import { ManagementCategories } from './manage'; +import { NavLinkItem } from '../../common/components/navigation/types'; const RULES_ITEM_LABEL = 'elastic rules!'; const EXCEPTIONS_ITEM_LABEL = 'exceptional!'; +const CATEGORY_1_LABEL = 'first tests category'; +const CATEGORY_2_LABEL = 'second tests category'; -const mockAppManageLink: NavLinkItem = { +const defaultAppManageLink: NavLinkItem = { id: SecurityPageName.administration, - path: '', title: 'admin', + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.rules], + }, + { + label: CATEGORY_2_LABEL, + linkIds: [SecurityPageName.exceptions], + }, + ], links: [ { id: SecurityPageName.rules, title: RULES_ITEM_LABEL, description: '', icon: 'testIcon1', - path: '', }, { id: SecurityPageName.exceptions, title: EXCEPTIONS_ITEM_LABEL, description: '', icon: 'testIcon2', - path: '', }, ], }; + +const mockedUseCanSeeHostIsolationExceptionsMenu = jest.fn(); +jest.mock('../../management/pages/host_isolation_exceptions/view/hooks', () => ({ + useCanSeeHostIsolationExceptionsMenu: () => mockedUseCanSeeHostIsolationExceptionsMenu(), +})); + +const mockAppManageLink = jest.fn(() => defaultAppManageLink); jest.mock('../../common/components/navigation/nav_links', () => ({ - useAppRootNavLink: jest.fn(() => mockAppManageLink), + useAppRootNavLink: () => mockAppManageLink(), })); -describe('LandingCategories', () => { - it('renders items', () => { +describe('ManagementCategories', () => { + it('should render items', () => { const { queryByText } = render( - + ); @@ -63,17 +68,19 @@ describe('LandingCategories', () => { expect(queryByText(EXCEPTIONS_ITEM_LABEL)).toBeInTheDocument(); }); - it('renders items in the same order as defined', () => { + it('should render items in the same order as defined', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: '', + linkIds: [SecurityPageName.exceptions, SecurityPageName.rules], + }, + ], + }); const { queryAllByTestId } = render( - + ); @@ -82,4 +89,109 @@ describe('LandingCategories', () => { expect(renderedItems[0]).toHaveTextContent(EXCEPTIONS_ITEM_LABEL); expect(renderedItems[1]).toHaveTextContent(RULES_ITEM_LABEL); }); + + it('should not render category items filtered', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + ], + links: [ + { + id: SecurityPageName.rules, + title: RULES_ITEM_LABEL, + description: '', + icon: 'testIcon1', + }, + ], + }); + const { queryAllByTestId } = render( + + + + ); + + const renderedItems = queryAllByTestId('LandingItem'); + + expect(renderedItems).toHaveLength(1); + expect(renderedItems[0]).toHaveTextContent(RULES_ITEM_LABEL); + }); + + it('should not render category if all items filtered', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + links: [], + }); + const { queryByText } = render( + + + + ); + + expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument(); + expect(queryByText(CATEGORY_2_LABEL)).not.toBeInTheDocument(); + }); + + it('should not render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is false', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValueOnce(false); + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.hostIsolationExceptions], + }, + ], + links: [ + { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions title', + description: 'test hostIsolationExceptions description', + icon: 'testIcon1', + }, + ], + }); + const { queryByText } = render( + + + + ); + + expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument(); + }); + + it('should render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is true', () => { + const HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL = 'test hostIsolationExceptions title'; + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValueOnce(true); + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.hostIsolationExceptions], + }, + ], + links: [ + { + id: SecurityPageName.hostIsolationExceptions, + title: HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL, + description: 'test hostIsolationExceptions description', + icon: 'testIcon1', + }, + ], + }); + const { queryAllByTestId } = render( + + + + ); + + const renderedItems = queryAllByTestId('LandingItem'); + + expect(renderedItems).toHaveLength(1); + expect(renderedItems[0]).toHaveTextContent(HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL); + }); }); diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx index f0e6094d5113fe..d484e5fe90a52e 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx @@ -11,18 +11,18 @@ import styled from 'styled-components'; import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; -import { NavigationCategories } from '../../common/components/navigation/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { navigationCategories } from '../../management/links'; +import { useCanSeeHostIsolationExceptionsMenu } from '../../management/pages/host_isolation_exceptions/view/hooks'; import { LandingLinksIcons } from '../components/landing_links_icons'; import { MANAGE_PAGE_TITLE } from './translations'; export const ManageLandingPage = () => ( - - + + ); @@ -31,37 +31,52 @@ const StyledEuiHorizontalRule = styled(EuiHorizontalRule)` margin-bottom: ${({ theme }) => theme.eui.paddingSizes.l}; `; -const useGetManageNavLinks = () => { - const manageNavLinks = useAppRootNavLink(SecurityPageName.administration)?.links ?? []; +type ManagementCategories = Array<{ label: string; links: NavLinkItem[] }>; +const useManagementCategories = (): ManagementCategories => { + const hideHostIsolationExceptions = !useCanSeeHostIsolationExceptionsMenu(); + const { links = [], categories = [] } = useAppRootNavLink(SecurityPageName.administration) ?? {}; - const manageLinksById = Object.fromEntries(manageNavLinks.map((link) => [link.id, link])); - return (linkIds: readonly SecurityPageName[]) => linkIds.map((linkId) => manageLinksById[linkId]); + const manageLinksById = Object.fromEntries(links.map((link) => [link.id, link])); + + return categories.reduce((acc, { label, linkIds }) => { + const linksItem = linkIds.reduce((linksAcc, linkId) => { + if ( + manageLinksById[linkId] && + !(linkId === SecurityPageName.hostIsolationExceptions && hideHostIsolationExceptions) + ) { + linksAcc.push(manageLinksById[linkId]); + } + return linksAcc; + }, []); + if (linksItem.length > 0) { + acc.push({ label, links: linksItem }); + } + return acc; + }, []); }; -export const LandingCategories = React.memo( - ({ categories }: { categories: NavigationCategories }) => { - const getManageNavLinks = useGetManageNavLinks(); +export const ManagementCategories = () => { + const managementCategories = useManagementCategories(); - return ( - <> - {categories.map(({ label, linkIds }, index) => ( -
- {index > 0 && ( - <> - - - - )} - -

{label}

-
- - -
- ))} - - ); - } -); + return ( + <> + {managementCategories.map(({ label, links }, index) => ( +
+ {index > 0 && ( + <> + + + + )} + +

{label}

+
+ + +
+ ))} + + ); +}; -LandingCategories.displayName = 'LandingCategories'; +ManagementCategories.displayName = 'ManagementCategories'; diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index ee60274cbb83df..9316f92a0d0b80 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { BLOCKLIST_PATH, @@ -17,6 +18,7 @@ import { RULES_CREATE_PATH, RULES_PATH, SecurityPageName, + SERVER_APP_ID, TRUSTED_APPS_PATH, } from '../../common/constants'; import { @@ -31,8 +33,8 @@ import { RULES, TRUSTED_APPLICATIONS, } from '../app/translations'; -import { NavigationCategories } from '../common/components/navigation/types'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; +import { StartPlugins } from '../types'; import { IconBlocklist } from './icons/blocklist'; import { IconEndpoints } from './icons/endpoints'; @@ -43,19 +45,42 @@ import { IconHostIsolation } from './icons/host_isolation'; import { IconSiemRules } from './icons/siem_rules'; import { IconTrustedApplications } from './icons/trusted_applications'; -export const links: LinkItem = { +const categories = [ + { + label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { + defaultMessage: 'SIEM', + }), + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + { + label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { + defaultMessage: 'ENDPOINTS', + }), + linkIds: [ + SecurityPageName.endpoints, + SecurityPageName.policies, + SecurityPageName.trustedApps, + SecurityPageName.eventFilters, + SecurityPageName.hostIsolationExceptions, + SecurityPageName.blocklist, + ], + }, +]; + +const links: LinkItem = { id: SecurityPageName.administration, title: MANAGE, path: MANAGE_PATH, skipUrlState: true, hideTimeline: true, globalNavEnabled: false, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.manage', { defaultMessage: 'Manage', }), ], + categories, links: [ { id: SecurityPageName.rules, @@ -73,7 +98,6 @@ export const links: LinkItem = { defaultMessage: 'Rules', }), ], - globalSearchEnabled: true, links: [ { id: SecurityPageName.rulesCreate, @@ -99,7 +123,6 @@ export const links: LinkItem = { defaultMessage: 'Exception lists', }), ], - globalSearchEnabled: true, }, { id: SecurityPageName.endpoints, @@ -178,24 +201,7 @@ export const links: LinkItem = { ], }; -export const navigationCategories: NavigationCategories = [ - { - label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { - defaultMessage: 'SIEM', - }), - linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { - defaultMessage: 'ENDPOINTS', - }), - linkIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, -] as const; +export const getManagementLinkItems = async (core: CoreStart, plugins: StartPlugins) => { + // TODO: implement async logic to exclude links + return links; +}; diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts index 9fd06b523347f6..dbcc04b5c6d8e4 100644 --- a/x-pack/plugins/security_solution/public/overview/links.ts +++ b/x-pack/plugins/security_solution/public/overview/links.ts @@ -7,14 +7,14 @@ import { i18n } from '@kbn/i18n'; import { - DASHBOARDS_PATH, DETECTION_RESPONSE_PATH, LANDING_PATH, OVERVIEW_PATH, SecurityPageName, + SERVER_APP_ID, } from '../../common/constants'; -import { DASHBOARDS, DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; +import { LinkItem } from '../common/links/types'; import overviewPageImg from '../common/images/overview_page.png'; import detectionResponsePageImg from '../common/images/detection_response_page.png'; @@ -27,7 +27,7 @@ export const overviewLinks: LinkItem = { }), path: OVERVIEW_PATH, globalNavEnabled: true, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.overview', { defaultMessage: 'Overview', @@ -41,7 +41,7 @@ export const gettingStartedLinks: LinkItem = { title: GETTING_STARTED, path: LANDING_PATH, globalNavEnabled: false, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.getStarted', { defaultMessage: 'Getting started', @@ -62,26 +62,10 @@ export const detectionResponseLinks: LinkItem = { path: DETECTION_RESPONSE_PATH, globalNavEnabled: false, experimentalKey: 'detectionResponseEnabled', - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.detectionAndResponse', { defaultMessage: 'Detection & Response', }), ], }; - -export const dashboardsLandingLinks: LinkItem = { - id: SecurityPageName.dashboardsLanding, - title: DASHBOARDS, - path: DASHBOARDS_PATH, - globalNavEnabled: false, - features: [FEATURE.general], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.dashboards', { - defaultMessage: 'Dashboards', - }), - ], - links: [overviewLinks, detectionResponseLinks], - skipUrlState: true, - hideTimeline: true, -}; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 4b49c04f295a5c..1716e08febd40f 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -45,9 +45,11 @@ import { DETECTION_ENGINE_INDEX_URL, SERVER_APP_ID, SOURCERER_API_URL, + ENABLE_GROUPED_NAVIGATION, } from '../common/constants'; -import { getDeepLinks } from './app/deep_links'; +import { getDeepLinks, registerDeepLinksUpdater } from './app/deep_links'; +import { AppLinkItems, subscribeAppLinks, updateAppLinks } from './common/links'; import { getSubPluginRoutesByCapabilities, manageOldSiemRoutes } from './helpers'; import { SecurityAppStore } from './common/store/store'; import { licenseService } from './common/hooks/use_license'; @@ -140,7 +142,6 @@ export class Plugin implements IPlugin { // required to show the alert table inside cases const { alertsTableConfigurationRegistry } = plugins.triggersActionsUi; @@ -171,7 +172,15 @@ export class Plugin implements IPlugin { const [coreStart] = await core.getStartServices(); - manageOldSiemRoutes(coreStart); + + const subscription = subscribeAppLinks((links: AppLinkItems) => { + // It has to be called once after deep links are initialized + if (links.length > 0) { + manageOldSiemRoutes(coreStart); + subscription.unsubscribe(); + } + }); + return () => true; }, }); @@ -220,35 +229,65 @@ export class Plugin implements IPlugin { - if (currentLicense.type !== undefined) { - this.appUpdater$.next(() => ({ - navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks( - this.experimentalFeatures, - currentLicense.type, - core.application.capabilities - ), - })); + + if (newNavEnabled) { + registerDeepLinksUpdater(this.appUpdater$); + } + + // Not using await to prevent blocking start execution + this.lazyApplicationLinks().then(({ getAppLinks }) => { + getAppLinks(core, plugins).then((appLinks) => { + if (licensing !== null) { + this.licensingSubscription = licensing.subscribe((currentLicense) => { + if (currentLicense.type !== undefined) { + updateAppLinks(appLinks, { + experimentalFeatures: this.experimentalFeatures, + license: currentLicense, + capabilities: core.application.capabilities, + }); + + if (!newNavEnabled) { + // TODO: remove block when nav flag no longer needed + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed + deepLinks: getDeepLinks( + this.experimentalFeatures, + currentLicense.type, + core.application.capabilities + ), + })); + } + } + }); + } else { + updateAppLinks(appLinks, { + experimentalFeatures: this.experimentalFeatures, + capabilities: core.application.capabilities, + }); + + if (!newNavEnabled) { + // TODO: remove block when nav flag no longer needed + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed + deepLinks: getDeepLinks( + this.experimentalFeatures, + undefined, + core.application.capabilities + ), + })); + } } }); - } else { - this.appUpdater$.next(() => ({ - navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks( - this.experimentalFeatures, - undefined, - core.application.capabilities - ), - })); - } + }); return {}; } @@ -296,11 +335,22 @@ export class Plugin implements IPlugin { describe('getColumnWidthFromType', () => { @@ -23,6 +24,32 @@ describe('helpers', () => { }); }); + describe('getRootCategory', () => { + const baseFields = ['@timestamp', '_id', 'message']; + + baseFields.forEach((field) => { + test(`it returns the 'base' category for the ${field} field`, () => { + expect( + getRootCategory({ + field, + browserFields: mockBrowserFields, + }) + ).toEqual('base'); + }); + }); + + test(`it echos the field name for a field that's NOT in the base category`, () => { + const field = 'test_field_1'; + + expect( + getRootCategory({ + field, + browserFields: mockBrowserFields, + }) + ).toEqual(field); + }); + }); + describe('getColumnHeaders', () => { test('should return a full object of ColumnHeader from the default header', () => { const expectedData = [ @@ -80,5 +107,202 @@ describe('helpers', () => { ); expect(getColumnHeaders(mockHeader, mockBrowserFields)).toEqual(expectedData); }); + + test('it should return the expected metadata for the `_id` field, which is one level deep, and belongs to the `base` category', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: '_id', + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + aggregatable: false, + category: 'base', + columnHeaderType: 'not-filtered', + description: 'Each document has an _id that uniquely identifies it', + esTypes: [], + example: 'Y-6TfmcB0WOhS6qyMv3s', + id: '_id', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + initialWidth: 180, + name: '_id', + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for a field one level deep that does NOT belong to the `base` category', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'test_field_1', // one level deep, but does NOT belong to the `base` category + initialWidth: 180, + }, + ]; + + const oneLevelDeep: BrowserFields = { + test_field_1: { + fields: { + test_field_1: { + aggregatable: true, + category: 'test_field_1', + esTypes: ['keyword'], + format: 'string', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + name: 'test_field_1', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + }, + }, + }; + + expect(getColumnHeaders(headers, oneLevelDeep)).toEqual([ + { + aggregatable: true, + category: 'test_field_1', + columnHeaderType: 'not-filtered', + esTypes: ['keyword'], + format: 'string', + id: 'test_field_1', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + initialWidth: 180, + name: 'test_field_1', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for a field that is more than one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'foo.bar', // two levels deep + initialWidth: 180, + }, + ]; + + const twoLevelsDeep: BrowserFields = { + foo: { + fields: { + 'foo.bar': { + aggregatable: true, + category: 'foo', + esTypes: ['keyword'], + format: 'string', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + name: 'foo.bar', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + }, + }, + }; + + expect(getColumnHeaders(headers, twoLevelsDeep)).toEqual([ + { + aggregatable: true, + category: 'foo', + columnHeaderType: 'not-filtered', + esTypes: ['keyword'], + format: 'string', + id: 'foo.bar', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + initialWidth: 180, + name: 'foo.bar', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for an UNKNOWN field one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'unknown', // one level deep, but not contained in the `BrowserFields` + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + columnHeaderType: 'not-filtered', + id: 'unknown', + initialWidth: 180, + }, + ]); + }); + + test('it should return the expected metadata for an UNKNOWN field that is more than one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'unknown.more.than.one.level', // more than one level deep, and not contained in the `BrowserFields` + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + columnHeaderType: 'not-filtered', + id: 'unknown.more.than.one.level', + initialWidth: 180, + }, + ]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts index b1ea4899615a6c..1779c39ce7b317 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts @@ -5,12 +5,28 @@ * 2.0. */ -import { get } from 'lodash/fp'; +import { has, get } from 'lodash/fp'; import { ColumnHeaderOptions } from '../../../../../../common/types'; import { BrowserFields } from '../../../../../common/containers/source'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; +/** + * Returns the root category for fields that are only one level, e.g. `_id` or `test_field_1` + * + * The `base` category will be returned for fields that are members of `base`, + * e.g. the `@timestamp`, `_id`, and `message` fields. + * + * The field name will be echoed-back for all other fields, e.g. `test_field_1` + */ +export const getRootCategory = ({ + browserFields, + field, +}: { + browserFields: BrowserFields; + field: string; +}): string => (has(`base.fields.${field}`, browserFields) ? 'base' : field); + /** Enriches the column headers with field details from the specified browserFields */ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], @@ -19,13 +35,14 @@ export const getColumnHeaders = ( return headers ? headers.map((header) => { const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + const category = + splitHeader.length > 1 + ? splitHeader[0] + : getRootCategory({ field: header.id, browserFields }); return { ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), + ...get([category, 'fields', header.id], browserFields), }; }) : []; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx index edc8faff1b5fc7..c459a9f05a6783 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { DefaultDraggable } from '../../../../../common/components/draggables'; import { EndpointHostIsolationStatus } from '../../../../../common/components/endpoint/host_isolation'; import { useHostIsolationStatus } from '../../../../../detections/containers/detection_engine/alerts/use_host_isolation_status'; import { AgentStatus } from '../../../../../common/components/endpoint/agent_status'; @@ -33,26 +32,11 @@ export const AgentStatuses = React.memo( }) => { const { isIsolated, agentStatus, pendingIsolation, pendingUnisolation } = useHostIsolationStatus({ agentId: value }); - const isolationFieldName = 'host.isolation'; return ( {agentStatus !== undefined ? ( - {isDraggable ? ( - - - - ) : ( - - )} + ) : ( @@ -60,21 +44,11 @@ export const AgentStatuses = React.memo( )} - - - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/links.ts b/x-pack/plugins/security_solution/public/timelines/links.ts index 1bdadb20cfa6dc..bd972efd8a02ac 100644 --- a/x-pack/plugins/security_solution/public/timelines/links.ts +++ b/x-pack/plugins/security_solution/public/timelines/links.ts @@ -6,16 +6,16 @@ */ import { i18n } from '@kbn/i18n'; -import { SecurityPageName, TIMELINES_PATH } from '../../common/constants'; +import { SecurityPageName, SERVER_APP_ID, TIMELINES_PATH } from '../../common/constants'; import { TIMELINES } from '../app/translations'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; export const links: LinkItem = { id: SecurityPageName.timelines, title: TIMELINES, path: TIMELINES_PATH, globalNavEnabled: true, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.timelines', { defaultMessage: 'Timelines', @@ -29,6 +29,7 @@ export const links: LinkItem = { defaultMessage: 'Templates', }), path: `${TIMELINES_PATH}/template`, + sideNavDisabled: true, }, ], }; diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx index bb1f73765bf592..1684297fd236dd 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx @@ -27,7 +27,7 @@ const StyledEuiFlexGroup = styled(EuiFlexGroup)` margin-top: ${({ theme }) => theme.eui.paddingSizes.l}; `; -const RISKY_USERS_DASHBOARD_TITLE = 'User Risk Score (Start Here)'; +const RISKY_USERS_DASHBOARD_TITLE = 'Current Risk Score For Users'; const UserRiskTabBodyComponent: React.FC< Pick & { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts index d97eff43aeb8d4..04e8f2130e88ff 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts @@ -51,6 +51,9 @@ describe('schedule_notification_actions', () => { note: '# sample markdown', version: 1, exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', }; it('Should schedule actions with unflatted and legacy context', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts index d7293275c9c499..72ddb96301c475 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts @@ -59,6 +59,9 @@ describe('schedule_throttle_notification_actions', () => { note: '# sample markdown', version: 1, exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts index 2622493a51dc18..54bf6133f9e37f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -90,4 +90,7 @@ export const getOutputRuleAlertForRest = (): Omit< note: '# Investigative notes', version: 1, execution_summary: undefined, + related_integrations: [], + required_fields: [], + setup: '', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts index d603784fc70811..8f87c1cdc0467a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts @@ -117,10 +117,13 @@ export const importRules = async ({ index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -192,9 +195,12 @@ export const importRules = async ({ interval, maxSignals, name, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, tags, @@ -250,10 +256,13 @@ export const importRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts index 0b8c49cdb4d170..833361e7e22bf1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts @@ -63,6 +63,9 @@ export const ruleOutput = (): RulesSchema => ({ note: '# Investigative notes', timeline_title: 'some-timeline-title', timeline_id: 'some-timeline-id', + related_integrations: [], + required_fields: [], + setup: '', }); describe('validate', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index 5768306999f79c..083f495366480d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -126,6 +126,9 @@ describe('buildAlert', () => { ], to: 'now', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', version: 1, exceptions_list: [ { @@ -303,6 +306,9 @@ describe('buildAlert', () => { ], to: 'now', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', version: 1, exceptions_list: [ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts index 1a41adb4f6da52..3c7acccae703aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts @@ -32,11 +32,14 @@ export const getCreateRulesOptionsMock = (): CreateRulesOptions => ({ index: ['index-123'], interval: '5m', maxSignals: 100, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleNameOverride: undefined, outputIndex: 'output-1', name: 'Query with a rule id', + setup: undefined, severity: 'high', severityMapping: [], tags: [], @@ -85,11 +88,14 @@ export const getCreateMlRulesOptionsMock = (): CreateRulesOptions => ({ index: ['index-123'], interval: '5m', maxSignals: 100, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleNameOverride: undefined, outputIndex: 'output-1', name: 'Machine Learning Job', + setup: undefined, severity: 'high', severityMapping: [], tags: [], @@ -141,12 +147,15 @@ export const getCreateThreatMatchRulesOptionsMock = (): CreateRulesOptions => ({ outputIndex: 'output-1', query: 'user.name: root or user.name: admin', references: ['http://www.example.com'], + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleId: 'rule-1', ruleNameOverride: undefined, rulesClient: rulesClientMock.create(), savedId: 'savedId-123', + setup: undefined, severity: 'high', severityMapping: [], tags: [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 24017adc20626c..726964cdf3596e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -46,11 +46,14 @@ export const createRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, outputIndex, name, + setup, severity, severityMapping, tags, @@ -109,9 +112,12 @@ export const createRules = async ({ : undefined, filters, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, threat, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts index 04d8e66a076fb7..cab22e136f529c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts @@ -6,6 +6,8 @@ */ import uuid from 'uuid'; +import { SanitizedRule } from '@kbn/alerting-plugin/common'; +import { RuleParams } from '../schemas/rule_schemas'; import { duplicateRule } from './duplicate_rule'; jest.mock('uuid', () => ({ @@ -13,120 +15,287 @@ jest.mock('uuid', () => ({ })); describe('duplicateRule', () => { - it('should return a copy of rule with new ruleId', () => { - (uuid.v4 as jest.Mock).mockReturnValue('newId'); - - expect( - duplicateRule({ - id: 'oldTestRuleId', - notifyWhen: 'onActiveAlert', - name: 'test', - tags: ['test'], - alertTypeId: 'siem.signals', - consumer: 'siem', - params: { - savedId: undefined, - author: [], - description: 'test', - ruleId: 'oldTestRuleId', - falsePositives: [], - from: 'now-360s', - immutable: false, - license: '', - outputIndex: '.siem-signals-default', - meta: undefined, - maxSignals: 100, - riskScore: 42, - riskScoreMapping: [], - severity: 'low', - severityMapping: [], - threat: [], - to: 'now', - references: [], - version: 1, - exceptionsList: [], - type: 'query', - language: 'kuery', - index: [], - query: 'process.args : "chmod"', - filters: [], - buildingBlockType: undefined, - namespace: undefined, - note: undefined, - timelineId: undefined, - timelineTitle: undefined, - ruleNameOverride: undefined, - timestampOverride: undefined, - }, - schedule: { - interval: '5m', - }, + const createTestRule = (): SanitizedRule => ({ + id: 'some id', + notifyWhen: 'onActiveAlert', + name: 'Some rule', + tags: ['some tag'], + alertTypeId: 'siem.queryRule', + consumer: 'siem', + params: { + savedId: undefined, + author: [], + description: 'Some description.', + ruleId: 'some ruleId', + falsePositives: [], + from: 'now-360s', + immutable: false, + license: '', + outputIndex: '.siem-signals-default', + meta: undefined, + maxSignals: 100, + relatedIntegrations: [], + requiredFields: [], + riskScore: 42, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + setup: 'Some setup guide.', + threat: [], + to: 'now', + references: [], + version: 1, + exceptionsList: [], + type: 'query', + language: 'kuery', + index: [], + query: 'process.args : "chmod"', + filters: [], + buildingBlockType: undefined, + namespace: undefined, + note: undefined, + timelineId: undefined, + timelineTitle: undefined, + ruleNameOverride: undefined, + timestampOverride: undefined, + }, + schedule: { + interval: '5m', + }, + enabled: false, + actions: [], + throttle: null, + apiKeyOwner: 'kibana', + createdBy: 'kibana', + updatedBy: 'kibana', + muteAll: false, + mutedInstanceIds: [], + updatedAt: new Date(2021, 0), + createdAt: new Date(2021, 0), + scheduledTaskId: undefined, + executionStatus: { + lastExecutionDate: new Date(2021, 0), + status: 'ok', + }, + }); + + beforeAll(() => { + (uuid.v4 as jest.Mock).mockReturnValue('new ruleId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('returns an object with fields copied from a given rule', () => { + const rule = createTestRule(); + const result = duplicateRule(rule); + + expect(result).toEqual({ + name: expect.anything(), // covered in a separate test + params: { + ...rule.params, + ruleId: expect.anything(), // covered in a separate test + }, + tags: rule.tags, + alertTypeId: rule.alertTypeId, + consumer: rule.consumer, + schedule: rule.schedule, + actions: rule.actions, + throttle: null, // TODO: fix? + notifyWhen: null, // TODO: fix? + enabled: false, // covered in a separate test + }); + }); + + it('appends [Duplicate] to the name', () => { + const rule = createTestRule(); + rule.name = 'PowerShell Keylogging Script'; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + name: 'PowerShell Keylogging Script [Duplicate]', + }) + ); + }); + + it('generates a new ruleId', () => { + const rule = createTestRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + ruleId: 'new ruleId', + }), + }) + ); + }); + + it('makes sure the duplicated rule is disabled', () => { + const rule = createTestRule(); + rule.enabled = true; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ enabled: false, - actions: [], - throttle: null, - apiKeyOwner: 'kibana', - createdBy: 'kibana', - updatedBy: 'kibana', - muteAll: false, - mutedInstanceIds: [], - updatedAt: new Date(2021, 0), - createdAt: new Date(2021, 0), - scheduledTaskId: undefined, - executionStatus: { - lastExecutionDate: new Date(2021, 0), - status: 'ok', - }, }) - ).toMatchInlineSnapshot(` - Object { - "actions": Array [], - "alertTypeId": "siem.queryRule", - "consumer": "siem", - "enabled": false, - "name": "test [Duplicate]", - "notifyWhen": null, - "params": Object { - "author": Array [], - "buildingBlockType": undefined, - "description": "test", - "exceptionsList": Array [], - "falsePositives": Array [], - "filters": Array [], - "from": "now-360s", - "immutable": false, - "index": Array [], - "language": "kuery", - "license": "", - "maxSignals": 100, - "meta": undefined, - "namespace": undefined, - "note": undefined, - "outputIndex": ".siem-signals-default", - "query": "process.args : \\"chmod\\"", - "references": Array [], - "riskScore": 42, - "riskScoreMapping": Array [], - "ruleId": "newId", - "ruleNameOverride": undefined, - "savedId": undefined, - "severity": "low", - "severityMapping": Array [], - "threat": Array [], - "timelineId": undefined, - "timelineTitle": undefined, - "timestampOverride": undefined, - "to": "now", - "type": "query", - "version": 1, + ); + }); + + describe('when duplicating a prebuilt (immutable) rule', () => { + const createPrebuiltRule = () => { + const rule = createTestRule(); + rule.params.immutable = true; + return rule; + }; + + it('transforms it to a custom (mutable) rule', () => { + const rule = createPrebuiltRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + immutable: false, + }), + }) + ); + }); + + it('resets related integrations to an empty array', () => { + const rule = createPrebuiltRule(); + rule.params.relatedIntegrations = [ + { + package: 'aws', + version: '~1.2.3', + integration: 'route53', }, - "schedule": Object { - "interval": "5m", + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + relatedIntegrations: [], + }), + }) + ); + }); + + it('resets required fields to an empty array', () => { + const rule = createPrebuiltRule(); + rule.params.requiredFields = [ + { + name: 'event.action', + type: 'keyword', + ecs: true, }, - "tags": Array [ - "test", - ], - "throttle": null, - } - `); + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + requiredFields: [], + }), + }) + ); + }); + + it('resets setup guide to an empty string', () => { + const rule = createPrebuiltRule(); + rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + setup: '', + }), + }) + ); + }); + }); + + describe('when duplicating a custom (mutable) rule', () => { + const createCustomRule = () => { + const rule = createTestRule(); + rule.params.immutable = false; + return rule; + }; + + it('keeps it custom', () => { + const rule = createCustomRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + immutable: false, + }), + }) + ); + }); + + it('copies related integrations as is', () => { + const rule = createCustomRule(); + rule.params.relatedIntegrations = [ + { + package: 'aws', + version: '~1.2.3', + integration: 'route53', + }, + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + relatedIntegrations: rule.params.relatedIntegrations, + }), + }) + ); + }); + + it('copies required fields as is', () => { + const rule = createCustomRule(); + rule.params.requiredFields = [ + { + name: 'event.action', + type: 'keyword', + ecs: true, + }, + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + requiredFields: rule.params.requiredFields, + }), + }) + ); + }); + + it('copies setup guide as is', () => { + const rule = createCustomRule(); + rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + setup: rule.params.setup, + }), + }) + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts index 4ef21d04505176..81af1533498eec 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts @@ -22,7 +22,16 @@ const DUPLICATE_TITLE = i18n.translate( ); export const duplicateRule = (rule: SanitizedRule): InternalRuleCreate => { - const newRuleId = uuid.v4(); + // Generate a new static ruleId + const ruleId = uuid.v4(); + + // If it's a prebuilt rule, reset Related Integrations, Required Fields and Setup Guide. + // We do this because for now we don't allow the users to edit these fields for custom rules. + const isPrebuilt = rule.params.immutable; + const relatedIntegrations = isPrebuilt ? [] : rule.params.relatedIntegrations; + const requiredFields = isPrebuilt ? [] : rule.params.requiredFields; + const setup = isPrebuilt ? '' : rule.params.setup; + return { name: `${rule.name} [${DUPLICATE_TITLE}]`, tags: rule.tags, @@ -31,7 +40,10 @@ export const duplicateRule = (rule: SanitizedRule): InternalRuleCrea params: { ...rule.params, immutable: false, - ruleId: newRuleId, + ruleId, + relatedIntegrations, + requiredFields, + setup, }, schedule: rule.schedule, enabled: false, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index de80a8ba8c26b3..68fad65a8ff7e9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -85,6 +85,9 @@ describe('getExportAll', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index f297f375dda0b1..e31c1444cd9fc5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -82,6 +82,9 @@ describe('get_export_by_object_ids', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, @@ -191,6 +194,9 @@ describe('get_export_by_object_ids', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts index bffa0bc39eb91b..1ef4f14b17b6be 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -39,10 +39,13 @@ export const installPrepackagedRules = ( index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -95,10 +98,13 @@ export const installPrepackagedRules = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index ad2443b34fa959..e5f87b7cdb2e2d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -54,11 +54,14 @@ export const patchRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, rule, name, + setup, severity, severityMapping, tags, @@ -108,10 +111,13 @@ export const patchRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, @@ -158,9 +164,12 @@ export const patchRules = async ({ filters, index, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, threat, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 8b560d0edea0f1..eeb0e88e53d474 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -93,6 +93,9 @@ import { RuleNameOverrideOrUndefined, EventCategoryOverrideOrUndefined, NamespaceOrUndefined, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../../../../common/detection_engine/schemas/common'; import { PartialFilter } from '../types'; @@ -161,11 +164,14 @@ export interface CreateRulesOptions { interval: Interval; license: LicenseOrUndefined; maxSignals: MaxSignals; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScore; riskScoreMapping: RiskScoreMapping; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndex; name: Name; + setup: SetupGuide | undefined; severity: Severity; severityMapping: SeverityMapping; tags: Tags; @@ -225,11 +231,14 @@ interface PatchRulesFieldsOptions { interval: IntervalOrUndefined; license: LicenseOrUndefined; maxSignals: MaxSignalsOrUndefined; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScoreOrUndefined; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndexOrUndefined; name: NameOrUndefined; + setup: SetupGuide | undefined; severity: SeverityOrUndefined; severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index ad35e11d35668a..079af5b82d608d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -83,10 +83,13 @@ export const createPromises = ( index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -169,10 +172,13 @@ export const createPromises = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, @@ -220,10 +226,13 @@ export const createPromises = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index ba65b76f01c4ab..7c981a5481ff99 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -54,9 +54,12 @@ export const updateRules = async ({ timelineTitle: ruleUpdate.timeline_title, meta: ruleUpdate.meta, maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, + relatedIntegrations: existingRule.params.relatedIntegrations, + requiredFields: existingRule.params.requiredFields, riskScore: ruleUpdate.risk_score, riskScoreMapping: ruleUpdate.risk_score_mapping ?? [], ruleNameOverride: ruleUpdate.rule_name_override, + setup: existingRule.params.setup, severity: ruleUpdate.severity, severityMapping: ruleUpdate.severity_mapping ?? [], threat: ruleUpdate.threat ?? [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts index 0952da3182e016..43ac38f447abc5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts @@ -127,10 +127,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, @@ -179,10 +182,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, @@ -231,10 +237,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index dd25676a758e49..4ac138e1629f38 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -56,7 +56,10 @@ import { TimestampOverrideOrUndefined, EventCategoryOverrideOrUndefined, NamespaceOrUndefined, -} from '../../../../common/detection_engine/schemas/common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../../../../common/detection_engine/schemas/common'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { @@ -107,11 +110,14 @@ export interface UpdateProperties { index: IndexOrUndefined; interval: IntervalOrUndefined; maxSignals: MaxSignalsOrUndefined; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScoreOrUndefined; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndexOrUndefined; name: NameOrUndefined; + setup: SetupGuide | undefined; severity: SeverityOrUndefined; severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts index fd80bec1f6ad98..356436058b55c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts @@ -161,6 +161,9 @@ export const convertCreateAPIToInternalSchema = ( note: input.note, version: input.version ?? 1, exceptionsList: input.exceptions_list ?? [], + relatedIntegrations: [], + requiredFields: [], + setup: '', ...typeSpecificParams, }, schedule: { interval: input.interval ?? '5m' }, @@ -276,6 +279,9 @@ export const commonParamsCamelToSnake = (params: BaseRuleParams) => { version: params.version, exceptions_list: params.exceptionsList, immutable: params.immutable, + related_integrations: params.relatedIntegrations ?? [], + required_fields: params.requiredFields ?? [], + setup: params.setup ?? '', }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts index edaacf38d77127..9e3fa6a906da92 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts @@ -51,6 +51,9 @@ const getBaseRuleParams = (): BaseRuleParams => { threat: getThreatMock(), version: 1, exceptionsList: getListArrayMock(), + relatedIntegrations: [], + requiredFields: [], + setup: '', }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts index 47e49e5f9c467b..d1776136f65135 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts @@ -72,7 +72,10 @@ import { updatedByOrNull, created_at, updated_at, -} from '../../../../common/detection_engine/schemas/common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../../../../common/detection_engine/schemas/common'; import { SERVER_APP_ID } from '../../../../common/constants'; const nonEqlLanguages = t.keyof({ kuery: null, lucene: null }); @@ -105,6 +108,9 @@ export const baseRuleParams = t.exact( references, version, exceptionsList: listArray, + relatedIntegrations: t.union([RelatedIntegrationArray, t.undefined]), + requiredFields: t.union([RequiredFieldArray, t.undefined]), + setup: t.union([SetupGuide, t.undefined]), }) ); export type BaseRuleParams = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 9213d6c5b278c2..03074b95605539 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -157,6 +157,9 @@ export const expectedRule = (): RulesSchema => { timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }; }; @@ -624,6 +627,9 @@ export const sampleSignalHit = (): SignalHit => ({ rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }, depth: 1, }, @@ -685,6 +691,9 @@ export const sampleThresholdSignalHit = (): SignalHit => ({ rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }, depth: 1, }, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index 59bc07f8ca2eb7..f6e3ca6e9d8efe 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -256,6 +256,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { malicious_behavior_rules: maliciousBehaviorRules, system_impact: systemImpact, threads, + event_filter: eventFilter, } = endpoint.endpoint_metrics.Endpoint.metrics; const endpointPolicyDetail = extractEndpointPolicyConfig(policyConfig); @@ -275,6 +276,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { maliciousBehaviorRules, systemImpact, threads, + eventFilter, }, endpoint_meta: { os: endpoint.endpoint_metrics.host.os, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index 15c92740e3a71b..d70a011ea85aa8 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -233,6 +233,10 @@ export interface EndpointMetrics { library_load_events?: SystemImpactEventsMetrics; }>; threads: Array<{ name: string; cpu: { mean: number } }>; + event_filter: { + active_global_count: number; + active_user_count: number; + }; } interface EndpointMetricOS { diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index b389f3b0effaba..592837c2e20dd8 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -160,7 +160,7 @@ export const initUiSettings = ( } ), category: [APP_ID], - requiresPageReload: false, + requiresPageReload: true, schema: schema.boolean(), }, } diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 1316313427c5e7..cff05c5c1003b3 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -295,13 +295,19 @@ describe('ProcessTreeNode component', () => { describe('Search', () => { it('highlights text within the process node line item if it matches the searchQuery', () => { // set a mock search matched indicator for the process (typically done by ProcessTree/helpers.ts) - processMock.searchMatched = '/vagrant'; + processMock.searchMatched = '/vagr'; renderResult = mockedContext.render(); expect( renderResult.getByTestId('sessionView:processNodeSearchHighlight').textContent - ).toEqual('/vagrant'); + ).toEqual('/vagr'); + + // ensures we are showing the rest of the info, and not replacing it with just the match. + const { process } = props.process.getDetails(); + expect(renderResult.container.textContent).toContain( + process?.working_directory + '\xA0' + (process?.args && process.args.join(' ')) + ); }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index 4d6074497af5ab..f65cb0f25530a0 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -146,7 +146,7 @@ export function ProcessTreeNode({ }); // eslint-disable-next-line no-unsanitized/property - textRef.current.innerHTML = html; + textRef.current.innerHTML = '' + html + ''; } } }, [searchMatched, styles.searchHighlight]); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts index b68df480064b35..54dbdb1bc4565f 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts +++ b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts @@ -117,7 +117,6 @@ export const useStyles = ({ fontSize: FONT_SIZE, lineHeight: LINE_HEIGHT, verticalAlign: 'middle', - display: 'inline-block', }, }; @@ -165,6 +164,7 @@ export const useStyles = ({ paddingLeft: size.xxl, position: 'relative', lineHeight: LINE_HEIGHT, + marginTop: '1px', }; const alertDetails: CSSObject = { diff --git a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx index 6e268d4711bb5a..6f5158423ca517 100644 --- a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx +++ b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx @@ -69,7 +69,6 @@ class SpacesMenuUI extends Component { id: 'xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', defaultMessage: 'Change current space', }), - watchedItemProps: ['data-search-term'], }; if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { diff --git a/x-pack/plugins/stack_alerts/kibana.json b/x-pack/plugins/stack_alerts/kibana.json index abafba8010fbc2..ff436ef53fae7b 100644 --- a/x-pack/plugins/stack_alerts/kibana.json +++ b/x-pack/plugins/stack_alerts/kibana.json @@ -14,7 +14,8 @@ "triggersActionsUi", "kibanaReact", "savedObjects", - "data" + "data", + "kibanaUtils" ], "configPath": ["xpack", "stack_alerts"], "requiredBundles": ["esUiShared"], diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx new file mode 100644 index 00000000000000..94e6a6b0c0cd44 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; +import { DataViewSelectPopover } from './data_view_select_popover'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { act } from 'react-dom/test-utils'; + +const props = { + onSelectDataView: () => {}, + initialDataViewTitle: 'kibana_sample_data_logs', + initialDataViewId: 'mock-data-logs-id', +}; + +const dataViewOptions = [ + { + id: 'mock-data-logs-id', + namespaces: ['default'], + title: 'kibana_sample_data_logs', + }, + { + id: 'mock-flyghts-id', + namespaces: ['default'], + title: 'kibana_sample_data_flights', + }, + { + id: 'mock-ecommerce-id', + namespaces: ['default'], + title: 'kibana_sample_data_ecommerce', + typeMeta: {}, + }, + { + id: 'mock-test-id', + namespaces: ['default'], + title: 'test', + typeMeta: {}, + }, +]; + +const mount = () => { + const dataViewsMock = dataViewPluginMocks.createStartContract(); + dataViewsMock.getIdsWithTitle.mockImplementation(() => Promise.resolve(dataViewOptions)); + + return { + wrapper: mountWithIntl( + + + + ), + dataViewsMock, + }; +}; + +describe('DataViewSelectPopover', () => { + test('renders properly', async () => { + const { wrapper, dataViewsMock } = mount(); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(dataViewsMock.getIdsWithTitle).toHaveBeenCalled(); + expect(wrapper.find('[data-test-subj="selectDataViewExpression"]').exists()).toBeTruthy(); + + const getIdsWithTitleResult = await dataViewsMock.getIdsWithTitle.mock.results[0].value; + expect(getIdsWithTitleResult).toBe(dataViewOptions); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx new file mode 100644 index 00000000000000..a62b640e0d8eb4 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { DataViewsList } from '@kbn/unified-search-plugin/public'; +import { DataViewListItem } from '@kbn/data-views-plugin/public'; +import { useTriggersAndActionsUiDeps } from '../es_query/util'; + +interface DataViewSelectPopoverProps { + onSelectDataView: (newDataViewId: string) => void; + initialDataViewTitle: string; + initialDataViewId?: string; +} + +export const DataViewSelectPopover: React.FunctionComponent = ({ + onSelectDataView, + initialDataViewTitle, + initialDataViewId, +}) => { + const { data } = useTriggersAndActionsUiDeps(); + const [dataViewItems, setDataViewsItems] = useState(); + const [dataViewPopoverOpen, setDataViewPopoverOpen] = useState(false); + + const [selectedDataViewId, setSelectedDataViewId] = useState(initialDataViewId); + const [selectedTitle, setSelectedTitle] = useState(initialDataViewTitle); + + useEffect(() => { + const initDataViews = async () => { + const fetchedDataViewItems = await data.dataViews.getIdsWithTitle(); + setDataViewsItems(fetchedDataViewItems); + }; + initDataViews(); + }, [data.dataViews]); + + const closeDataViewPopover = useCallback(() => setDataViewPopoverOpen(false), []); + + if (!dataViewItems) { + return null; + } + + return ( + { + setDataViewPopoverOpen(true); + }} + isInvalid={!selectedTitle} + /> + } + isOpen={dataViewPopoverOpen} + closePopover={closeDataViewPopover} + ownFocus + anchorPosition="downLeft" + display="block" + > +
+ + + + {i18n.translate('xpack.stackAlerts.components.ui.alertParams.dataViewPopoverTitle', { + defaultMessage: 'Data view', + })} + + + + + + + + { + setSelectedDataViewId(newId); + const newTitle = dataViewItems?.find(({ id }) => id === newId)?.title; + if (newTitle) { + setSelectedTitle(newTitle); + } + + onSelectDataView(newId); + closeDataViewPopover(); + }} + currentDataViewId={selectedDataViewId} + /> + +
+
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts index bceb39ba08cf9e..da85c878f32818 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts @@ -6,6 +6,7 @@ */ import { COMPARATORS } from '@kbn/triggers-actions-ui-plugin/public'; +import { ErrorKey } from './types'; export const DEFAULT_VALUES = { THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, @@ -19,3 +20,17 @@ export const DEFAULT_VALUES = { TIME_WINDOW_UNIT: 'm', THRESHOLD: [1000], }; + +export const EXPRESSION_ERRORS = { + index: new Array(), + size: new Array(), + timeField: new Array(), + threshold0: new Array(), + threshold1: new Array(), + esQuery: new Array(), + thresholdComparator: new Array(), + timeWindowSize: new Array(), + searchConfiguration: new Array(), +}; + +export const EXPRESSION_ERROR_KEYS = Object.keys(EXPRESSION_ERRORS) as ErrorKey[]; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx index 10b774648d735a..afb45f90c6e52f 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx @@ -83,6 +83,7 @@ export const EsQueryExpression = ({ thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, size: size ?? DEFAULT_VALUES.SIZE, esQuery: esQuery ?? DEFAULT_VALUES.QUERY, + searchType: 'esQuery', }); const setParam = useCallback( diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx index df44a8923183cb..3b5e978b999c88 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx @@ -5,29 +5,33 @@ * 2.0. */ -import React from 'react'; +import React, { memo, PropsWithChildren } from 'react'; import { i18n } from '@kbn/i18n'; +import deepEqual from 'fast-deep-equal'; import 'brace/theme/github'; import { EuiSpacer, EuiCallOut } from '@elastic/eui'; import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryAlertParams } from '../types'; -import { SearchSourceExpression } from './search_source_expression'; +import { ErrorKey, EsQueryAlertParams } from '../types'; +import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression'; import { EsQueryExpression } from './es_query_expression'; import { isSearchSourceAlert } from '../util'; +import { EXPRESSION_ERROR_KEYS } from '../constants'; -const expressionFieldsWithValidation = [ - 'index', - 'size', - 'timeField', - 'threshold0', - 'threshold1', - 'timeWindowSize', - 'searchType', - 'esQuery', - 'searchConfiguration', -]; +function areSearchSourceExpressionPropsEqual( + prevProps: Readonly>, + nextProps: Readonly> +) { + const areErrorsEqual = deepEqual(prevProps.errors, nextProps.errors); + const areRuleParamsEqual = deepEqual(prevProps.ruleParams, nextProps.ruleParams); + return areErrorsEqual && areRuleParamsEqual; +} + +const SearchSourceExpressionMemoized = memo( + SearchSourceExpression, + areSearchSourceExpressionPropsEqual +); export const EsQueryAlertTypeExpression: React.FunctionComponent< RuleTypeParamsExpressionProps @@ -35,11 +39,11 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent< const { ruleParams, errors } = props; const isSearchSource = isSearchSourceAlert(ruleParams); - const hasExpressionErrors = !!Object.keys(errors).find((errorKey) => { + const hasExpressionErrors = Object.keys(errors).some((errorKey) => { return ( - expressionFieldsWithValidation.includes(errorKey) && + EXPRESSION_ERROR_KEYS.includes(errorKey as ErrorKey) && errors[errorKey].length >= 1 && - ruleParams[errorKey as keyof EsQueryAlertParams] !== undefined + ruleParams[errorKey] !== undefined ); }); @@ -54,14 +58,13 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent< <> {hasExpressionErrors && ( <> - )} {isSearchSource ? ( - + ) : ( )} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx deleted file mode 100644 index 6747c60bb840cc..00000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { injectI18n } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; - -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { getDisplayValueFromFilter } from '@kbn/data-plugin/public'; -import { Filter } from '@kbn/data-plugin/common'; -import { DataView } from '@kbn/data-views-plugin/public'; -import { FilterItem } from '@kbn/unified-search-plugin/public'; - -const FilterItemComponent = injectI18n(FilterItem); - -interface ReadOnlyFilterItemsProps { - filters: Filter[]; - indexPatterns: DataView[]; -} - -const noOp = () => {}; - -export const ReadOnlyFilterItems = ({ filters, indexPatterns }: ReadOnlyFilterItemsProps) => { - const { uiSettings } = useKibana().services; - - const filterList = filters.map((filter, index) => { - const filterValue = getDisplayValueFromFilter(filter, indexPatterns); - return ( - - - - ); - }); - - return ( - - {filterList} - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx index 7041bba0fe2ff0..d12833a3f258f4 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx @@ -10,18 +10,12 @@ import React from 'react'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; -import { DataPublicPluginStart, ISearchStart } from '@kbn/data-plugin/public'; import { EsQueryAlertParams, SearchType } from '../types'; import { SearchSourceExpression } from './search_source_expression'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { act } from 'react-dom/test-utils'; -import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; -import { ReactWrapper } from 'enzyme'; - -const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - search: ISearchStart & { searchSource: { create: jest.MockedFunction } }; -}; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; const dataViewPluginMock = dataViewPluginMocks.createStartContract(); const chartsStartMock = chartPluginMock.createStartContract(); @@ -40,6 +34,18 @@ const defaultSearchSourceExpressionParams: EsQueryAlertParams { if (name === 'filter') { return []; @@ -48,7 +54,33 @@ const searchSourceMock = { }, }; -const setup = async (alertParams: EsQueryAlertParams) => { +const savedQueryMock = { + id: 'test-id', + attributes: { + title: 'test-filter-set', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, +}; + +jest.mock('./search_source_expression_form', () => ({ + SearchSourceExpressionForm: () =>
search source expression form mock
, +})); + +const dataMock = dataPluginMock.createStartContract(); +(dataMock.search.searchSource.create as jest.Mock).mockImplementation(() => + Promise.resolve(searchSourceMock) +); +(dataMock.dataViews.getIdsWithTitle as jest.Mock).mockImplementation(() => Promise.resolve([])); +(dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() => + Promise.resolve(savedQueryMock) +); + +const setup = (alertParams: EsQueryAlertParams) => { const errors = { size: [], timeField: [], @@ -57,67 +89,58 @@ const setup = async (alertParams: EsQueryAlertParams) = }; const wrapper = mountWithIntl( - {}} - setRuleProperty={() => {}} - errors={errors} - unifiedSearch={unifiedSearchMock} - data={dataMock} - dataViews={dataViewPluginMock} - defaultActionGroupId="" - actionGroups={[]} - charts={chartsStartMock} - /> + + {}} + setRuleProperty={() => {}} + errors={errors} + unifiedSearch={unifiedSearchMock} + data={dataMock} + dataViews={dataViewPluginMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + /> + ); return wrapper; }; -const rerender = async (wrapper: ReactWrapper) => { - const update = async () => +describe('SearchSourceAlertTypeExpression', () => { + test('should render correctly', async () => { + let wrapper = setup(defaultSearchSourceExpressionParams).children(); + + expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); + await act(async () => { await nextTick(); - wrapper.update(); }); - await update(); -}; + wrapper = await wrapper.update(); -describe('SearchSourceAlertTypeExpression', () => { - test('should render loading prompt', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.resolve(() => searchSourceMock) - ); - - const wrapper = await setup(defaultSearchSourceExpressionParams); - - expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); + expect(wrapper.text().includes('search source expression form mock')).toBeTruthy(); }); test('should render error prompt', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.reject(() => 'test error') + (dataMock.search.searchSource.create as jest.Mock).mockImplementationOnce(() => + Promise.reject(new Error('Cant find searchSource')) ); + let wrapper = setup(defaultSearchSourceExpressionParams).children(); - const wrapper = await setup(defaultSearchSourceExpressionParams); - await rerender(wrapper); - - expect(wrapper.find(EuiCallOut).exists()).toBeTruthy(); - }); - - test('should render SearchSourceAlertTypeExpression with expected components', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.resolve(() => searchSourceMock) - ); + expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); - const wrapper = await setup(defaultSearchSourceExpressionParams); - await rerender(wrapper); + await act(async () => { + await nextTick(); + }); + wrapper = await wrapper.update(); - expect(wrapper.find('[data-test-subj="sizeValueExpression"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx index 1d54609223aaff..26b2d074bfd8b5 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx @@ -5,36 +5,27 @@ * 2.0. */ -import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import './search_source_expression.scss'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiSpacer, - EuiTitle, - EuiExpression, - EuiLoadingSpinner, - EuiEmptyPrompt, - EuiCallOut, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { Filter, ISearchSource } from '@kbn/data-plugin/common'; -import { - ForLastExpression, - RuleTypeParamsExpressionProps, - ThresholdExpression, - ValueExpression, -} from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiSpacer, EuiLoadingSpinner, EuiEmptyPrompt, EuiCallOut } from '@elastic/eui'; +import { ISearchSource } from '@kbn/data-plugin/common'; +import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { SavedQuery } from '@kbn/data-plugin/public'; import { EsQueryAlertParams, SearchType } from '../types'; +import { useTriggersAndActionsUiDeps } from '../util'; +import { SearchSourceExpressionForm } from './search_source_expression_form'; import { DEFAULT_VALUES } from '../constants'; -import { ReadOnlyFilterItems } from './read_only_filter_items'; + +export type SearchSourceExpressionProps = RuleTypeParamsExpressionProps< + EsQueryAlertParams +>; export const SearchSourceExpression = ({ ruleParams, + errors, setRuleParams, setRuleProperty, - data, - errors, -}: RuleTypeParamsExpressionProps>) => { +}: SearchSourceExpressionProps) => { const { searchConfiguration, thresholdComparator, @@ -43,48 +34,43 @@ export const SearchSourceExpression = ({ timeWindowUnit, size, } = ruleParams; - const [usedSearchSource, setUsedSearchSource] = useState(); - const [paramsError, setParamsError] = useState(); + const { data } = useTriggersAndActionsUiDeps(); - const [currentAlertParams, setCurrentAlertParams] = useState< - EsQueryAlertParams - >({ - searchConfiguration, - searchType: SearchType.searchSource, - timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, - timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, - threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, - thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, - size: size ?? DEFAULT_VALUES.SIZE, - }); + const [searchSource, setSearchSource] = useState(); + const [savedQuery, setSavedQuery] = useState(); + const [paramsError, setParamsError] = useState(); const setParam = useCallback( - (paramField: string, paramValue: unknown) => { - setCurrentAlertParams((currentParams) => ({ - ...currentParams, - [paramField]: paramValue, - })); - setRuleParams(paramField, paramValue); - }, + (paramField: string, paramValue: unknown) => setRuleParams(paramField, paramValue), [setRuleParams] ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => setRuleProperty('params', currentAlertParams), []); + useEffect(() => { + setRuleProperty('params', { + searchConfiguration, + searchType: SearchType.searchSource, + timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, + threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, + thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, + size: size ?? DEFAULT_VALUES.SIZE, + }); + + const initSearchSource = () => + data.search.searchSource + .create(searchConfiguration) + .then((fetchedSearchSource) => setSearchSource(fetchedSearchSource)) + .catch(setParamsError); + + initSearchSource(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.search.searchSource, data.dataViews]); useEffect(() => { - async function initSearchSource() { - try { - const loadedSearchSource = await data.search.searchSource.create(searchConfiguration); - setUsedSearchSource(loadedSearchSource); - } catch (error) { - setParamsError(error); - } - } - if (searchConfiguration) { - initSearchSource(); + if (ruleParams.savedQueryId) { + data.query.savedQueries.getSavedQuery(ruleParams.savedQueryId).then(setSavedQuery); } - }, [data.search.searchSource, searchConfiguration]); + }, [data.query.savedQueries, ruleParams.savedQueryId]); if (paramsError) { return ( @@ -97,124 +83,17 @@ export const SearchSourceExpression = ({ ); } - if (!usedSearchSource) { + if (!searchSource) { return } />; } - const dataView = usedSearchSource.getField('index')!; - const query = usedSearchSource.getField('query')!; - const filters = (usedSearchSource.getField('filter') as Filter[]).filter( - ({ meta }) => !meta.disabled - ); - const dataViews = [dataView]; return ( - - -
- -
-
- - - } - iconType="iInCircle" - /> - - - {query.query !== '' && ( - - )} - {filters.length > 0 && ( - } - display="columns" - /> - )} - - - -
- -
-
- - - setParam('threshold', selectedThresholds) - } - onChangeSelectedThresholdComparator={(selectedThresholdComparator) => - setParam('thresholdComparator', selectedThresholdComparator) - } - /> - - setParam('timeWindowSize', selectedWindowSize) - } - onChangeWindowUnit={(selectedWindowUnit: string) => - setParam('timeWindowUnit', selectedWindowUnit) - } - /> - - -
- -
-
- - { - setParam('size', updatedValue); - }} - /> - -
+ ); }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx new file mode 100644 index 00000000000000..afd6a156187ee6 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Filter, DataView, Query, ISearchSource } from '@kbn/data-plugin/common'; +import { + ForLastExpression, + IErrorObject, + ThresholdExpression, + ValueExpression, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { SearchBar } from '@kbn/unified-search-plugin/public'; +import { mapAndFlattenFilters, SavedQuery, TimeHistory } from '@kbn/data-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { EsQueryAlertParams, SearchType } from '../types'; +import { DEFAULT_VALUES } from '../constants'; +import { DataViewSelectPopover } from '../../components/data_view_select_popover'; +import { useTriggersAndActionsUiDeps } from '../util'; + +interface LocalState { + index: DataView; + filter: Filter[]; + query: Query; + threshold: number[]; + timeWindowSize: number; + size: number; +} + +interface LocalStateAction { + type: SearchSourceParamsAction['type'] | ('threshold' | 'timeWindowSize' | 'size'); + payload: SearchSourceParamsAction['payload'] | (number[] | number); +} + +type LocalStateReducer = (prevState: LocalState, action: LocalStateAction) => LocalState; + +interface SearchSourceParamsAction { + type: 'index' | 'filter' | 'query'; + payload: DataView | Filter[] | Query; +} + +interface SearchSourceExpressionFormProps { + searchSource: ISearchSource; + ruleParams: EsQueryAlertParams; + errors: IErrorObject; + initialSavedQuery?: SavedQuery; + setParam: (paramField: string, paramValue: unknown) => void; +} + +const isSearchSourceParam = (action: LocalStateAction): action is SearchSourceParamsAction => { + return action.type === 'filter' || action.type === 'index' || action.type === 'query'; +}; + +export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProps) => { + const { data } = useTriggersAndActionsUiDeps(); + const { searchSource, ruleParams, errors, initialSavedQuery, setParam } = props; + const { thresholdComparator, timeWindowUnit } = ruleParams; + const [savedQuery, setSavedQuery] = useState(); + + const timeHistory = useMemo(() => new TimeHistory(new Storage(localStorage)), []); + + useEffect(() => setSavedQuery(initialSavedQuery), [initialSavedQuery]); + + const [{ index: dataView, query, filter: filters, threshold, timeWindowSize, size }, dispatch] = + useReducer( + (currentState, action) => { + if (isSearchSourceParam(action)) { + searchSource.setParent(undefined).setField(action.type, action.payload); + setParam('searchConfiguration', searchSource.getSerializedFields()); + } else { + setParam(action.type, action.payload); + } + return { ...currentState, [action.type]: action.payload }; + }, + { + index: searchSource.getField('index')!, + query: searchSource.getField('query')!, + filter: mapAndFlattenFilters(searchSource.getField('filter') as Filter[]), + threshold: ruleParams.threshold, + timeWindowSize: ruleParams.timeWindowSize, + size: ruleParams.size, + } + ); + const dataViews = useMemo(() => [dataView], [dataView]); + + const onSelectDataView = useCallback( + (newDataViewId) => + data.dataViews + .get(newDataViewId) + .then((newDataView) => dispatch({ type: 'index', payload: newDataView })), + [data.dataViews] + ); + + const onUpdateFilters = useCallback((newFilters) => { + dispatch({ type: 'filter', payload: mapAndFlattenFilters(newFilters) }); + }, []); + + const onChangeQuery = useCallback( + ({ query: newQuery }: { query?: Query }) => { + if (!deepEqual(newQuery, query)) { + dispatch({ type: 'query', payload: newQuery || { ...query, query: '' } }); + } + }, + [query] + ); + + // needs to change language mode only + const onQueryBarSubmit = ({ query: newQuery }: { query?: Query }) => { + if (newQuery?.language !== query.language) { + dispatch({ type: 'query', payload: { ...query, language: newQuery?.language } as Query }); + } + }; + + // Saved query + const onSavedQuery = useCallback((newSavedQuery: SavedQuery) => { + setSavedQuery(newSavedQuery); + const newFilters = newSavedQuery.attributes.filters; + if (newFilters) { + dispatch({ type: 'filter', payload: newFilters }); + } + }, []); + + const onClearSavedQuery = () => { + setSavedQuery(undefined); + dispatch({ type: 'query', payload: { ...query, query: '' } }); + }; + + // window size + const onChangeWindowUnit = useCallback( + (selectedWindowUnit: string) => setParam('timeWindowUnit', selectedWindowUnit), + [setParam] + ); + + const onChangeWindowSize = useCallback( + (selectedWindowSize?: number) => + selectedWindowSize && dispatch({ type: 'timeWindowSize', payload: selectedWindowSize }), + [] + ); + + // threshold + const onChangeSelectedThresholdComparator = useCallback( + (selectedThresholdComparator?: string) => + setParam('thresholdComparator', selectedThresholdComparator), + [setParam] + ); + + const onChangeSelectedThreshold = useCallback( + (selectedThresholds?: number[]) => + selectedThresholds && dispatch({ type: 'threshold', payload: selectedThresholds }), + [] + ); + + const onChangeSizeValue = useCallback( + (updatedValue: number) => dispatch({ type: 'size', payload: updatedValue }), + [] + ); + + return ( + + +
+ +
+
+ + + + + + + + + + + +
+ +
+
+ + + + + +
+ +
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts index bccf6ed4ced439..703570ad5faae8 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts @@ -7,6 +7,9 @@ import { RuleTypeParams } from '@kbn/alerting-plugin/common'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { EXPRESSION_ERRORS } from './constants'; export interface Comparator { text: string; @@ -19,7 +22,7 @@ export enum SearchType { searchSource = 'searchSource', } -export interface CommonAlertParams extends RuleTypeParams { +export interface CommonAlertParams extends RuleTypeParams { size: number; thresholdComparator?: string; threshold: number[]; @@ -28,8 +31,8 @@ export interface CommonAlertParams extends RuleTypeParams } export type EsQueryAlertParams = T extends SearchType.searchSource - ? CommonAlertParams & OnlySearchSourceAlertParams - : CommonAlertParams & OnlyEsQueryAlertParams; + ? CommonAlertParams & OnlySearchSourceAlertParams + : CommonAlertParams & OnlyEsQueryAlertParams; export interface OnlyEsQueryAlertParams { esQuery: string; @@ -39,4 +42,15 @@ export interface OnlyEsQueryAlertParams { export interface OnlySearchSourceAlertParams { searchType: 'searchSource'; searchConfiguration: SerializedSearchSourceFields; + savedQueryId?: string; +} + +export type DataViewOption = EuiComboBoxOptionOption; + +export type ExpressionErrors = typeof EXPRESSION_ERRORS; + +export type ErrorKey = keyof ExpressionErrors & unknown; + +export interface TriggersAndActionsUiDeps { + data: DataPublicPluginStart; } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts index 5b70da7cb3e80f..1f57a133fa65a1 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts @@ -5,10 +5,13 @@ * 2.0. */ -import { EsQueryAlertParams, SearchType } from './types'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { EsQueryAlertParams, SearchType, TriggersAndActionsUiDeps } from './types'; export const isSearchSourceAlert = ( ruleParams: EsQueryAlertParams ): ruleParams is EsQueryAlertParams => { return ruleParams.searchType === 'searchSource'; }; + +export const useTriggersAndActionsUiDeps = () => useKibana().services; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts index 914dd6a4f5f9f4..8a1135e75492f3 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts @@ -5,25 +5,17 @@ * 2.0. */ +import { defaultsDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ValidationResult, builtInComparators } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryAlertParams } from './types'; +import { EsQueryAlertParams, ExpressionErrors } from './types'; import { isSearchSourceAlert } from './util'; +import { EXPRESSION_ERRORS } from './constants'; export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => { const { size, threshold, timeWindowSize, thresholdComparator } = alertParams; const validationResult = { errors: {} }; - const errors = { - index: new Array(), - timeField: new Array(), - esQuery: new Array(), - size: new Array(), - threshold0: new Array(), - threshold1: new Array(), - thresholdComparator: new Array(), - timeWindowSize: new Array(), - searchConfiguration: new Array(), - }; + const errors: ExpressionErrors = defaultsDeep({}, EXPRESSION_ERRORS); validationResult.errors = errors; if (!threshold || threshold.length === 0 || threshold[0] === undefined) { errors.threshold0.push( diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap index 65dff2bd3a6c6d..fe53610caa316e 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap @@ -30,6 +30,7 @@ exports[`should render BoundaryIndexExpression 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } @@ -106,6 +107,7 @@ exports[`should render EntityIndexExpression 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } @@ -188,6 +190,7 @@ exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts index 468729fb2120d2..884bf606d2f90c 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts @@ -20,6 +20,7 @@ describe('ActionContext', () => { timeWindowUnit: 'm', thresholdComparator: '>', threshold: [4], + searchType: 'esQuery', }) as OnlyEsQueryAlertParams; const base: EsQueryAlertActionContext = { date: '2020-01-01T00:00:00.000Z', @@ -50,6 +51,7 @@ describe('ActionContext', () => { timeWindowUnit: 'm', thresholdComparator: 'between', threshold: [4, 5], + searchType: 'esQuery', }) as OnlyEsQueryAlertParams; const base: EsQueryAlertActionContext = { date: '2020-01-01T00:00:00.000Z', diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts index 3fce895a2bfd1f..3304ca5e902f73 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -110,6 +110,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.LT, threshold: [0], + searchType: 'esQuery', }; expect(alertType.validate?.params?.validate(params)).toBeTruthy(); @@ -128,6 +129,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.BETWEEN, threshold: [0], + searchType: 'esQuery', }; expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( @@ -145,6 +147,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.BETWEEN, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -174,6 +177,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -219,6 +223,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -267,6 +272,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -309,6 +315,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -380,6 +387,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -425,6 +433,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts index 5b41d7c55fe0a4..dfab69f445629e 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -7,10 +7,12 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Logger } from '@kbn/core/server'; +import { extractReferences, injectReferences } from '@kbn/data-plugin/common'; import { RuleType } from '../../types'; import { ActionContext } from './action_context'; import { EsQueryAlertParams, + EsQueryAlertParamsExtractedParams, EsQueryAlertParamsSchema, EsQueryAlertState, } from './alert_type_params'; @@ -18,13 +20,14 @@ import { STACK_ALERTS_FEATURE_ID } from '../../../common'; import { ExecutorOptions } from './types'; import { ActionGroupId, ES_QUERY_ID } from './constants'; import { executor } from './executor'; +import { isEsQueryAlert } from './util'; export function getAlertType( logger: Logger, core: CoreSetup ): RuleType< EsQueryAlertParams, - never, // Only use if defining useSavedObjectReferences hook + EsQueryAlertParamsExtractedParams, EsQueryAlertState, {}, ActionContext, @@ -159,6 +162,25 @@ export function getAlertType( { name: 'index', description: actionVariableContextIndexLabel }, ], }, + useSavedObjectReferences: { + extractReferences: (params) => { + if (isEsQueryAlert(params.searchType)) { + return { params: params as EsQueryAlertParamsExtractedParams, references: [] }; + } + const [searchConfiguration, references] = extractReferences(params.searchConfiguration); + const newParams = { ...params, searchConfiguration } as EsQueryAlertParamsExtractedParams; + return { params: newParams, references }; + }, + injectReferences: (params, references) => { + if (isEsQueryAlert(params.searchType)) { + return params; + } + return { + ...params, + searchConfiguration: injectReferences(params.searchConfiguration, references), + }; + }, + }, minimumLicenseRequired: 'basic', isExportable: true, executor: async (options: ExecutorOptions) => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts index d6ba0468b7cbfb..a1155fedb7a029 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts @@ -23,6 +23,7 @@ const DefaultParams: Writable> = { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; describe('alertType Params validate()', () => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts index f205fbd0327ce1..d32fce9debbc2e 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { validateTimeWindowUnits } from '@kbn/triggers-actions-ui-plugin/server'; import { RuleTypeState } from '@kbn/alerting-plugin/server'; +import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { Comparator } from '../../../common/comparator_types'; import { ComparatorFnNames } from '../lib'; import { getComparatorSchemaType } from '../lib/comparator'; @@ -21,13 +22,21 @@ export interface EsQueryAlertState extends RuleTypeState { latestTimestamp: string | undefined; } +export type EsQueryAlertParamsExtractedParams = Omit & { + searchConfiguration: SerializedSearchSourceFields & { + indexRefName: string; + }; +}; + const EsQueryAlertParamsSchemaProperties = { size: schema.number({ min: 0, max: ES_QUERY_MAX_HITS_PER_EXECUTION }), timeWindowSize: schema.number({ min: 1 }), timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }), threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), thresholdComparator: getComparatorSchemaType(validateComparator), - searchType: schema.nullable(schema.literal('searchSource')), + searchType: schema.oneOf([schema.literal('searchSource'), schema.literal('esQuery')], { + defaultValue: 'esQuery', + }), // searchSource alert param only searchConfiguration: schema.conditional( schema.siblingRef('searchType'), @@ -38,21 +47,21 @@ const EsQueryAlertParamsSchemaProperties = { // esQuery alert params only esQuery: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.string({ minLength: 1 }) + schema.literal('esQuery'), + schema.string({ minLength: 1 }), + schema.never() ), index: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }) + schema.literal('esQuery'), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + schema.never() ), timeField: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.string({ minLength: 1 }) + schema.literal('esQuery'), + schema.string({ minLength: 1 }), + schema.never() ), }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts index 670f76f5e19dea..7b4cc7521654bb 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts @@ -18,6 +18,7 @@ describe('es_query executor', () => { esQuery: '{ "query": "test-query" }', index: ['test-index'], timeField: '', + searchType: 'esQuery', }; describe('tryToParseAsDate', () => { it.each<[string | number]>([['2019-01-01T00:00:00.000Z'], [1546300800000]])( diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts index 44708a1df90fd8..6e47c5f471d884 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts @@ -16,13 +16,14 @@ import { fetchEsQuery } from './lib/fetch_es_query'; import { EsQueryAlertParams } from './alert_type_params'; import { fetchSearchSourceQuery } from './lib/fetch_search_source_query'; import { Comparator } from '../../../common/comparator_types'; +import { isEsQueryAlert } from './util'; export async function executor( logger: Logger, core: CoreSetup, options: ExecutorOptions ) { - const esQueryAlert = isEsQueryAlert(options); + const esQueryAlert = isEsQueryAlert(options.params.searchType); const { alertId, name, services, params, state } = options; const { alertFactory, scopedClusterClient, searchSourceClient } = services; const currentTimestamp = new Date().toISOString(); @@ -162,10 +163,6 @@ export function tryToParseAsDate(sortValue?: string | number | null): undefined } } -export function isEsQueryAlert(options: ExecutorOptions) { - return options.params.searchType !== 'searchSource'; -} - export function getChecksum(params: EsQueryAlertParams) { return sha256.create().update(JSON.stringify(params)); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts index 12b2ee02af1718..8595870a849405 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts @@ -10,7 +10,9 @@ import { ActionContext } from './action_context'; import { EsQueryAlertParams, EsQueryAlertState } from './alert_type_params'; import { ActionGroupId } from './constants'; -export type OnlyEsQueryAlertParams = Omit; +export type OnlyEsQueryAlertParams = Omit & { + searchType: 'esQuery'; +}; export type OnlySearchSourceAlertParams = Omit< EsQueryAlertParams, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts new file mode 100644 index 00000000000000..b58a362cd27e9a --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EsQueryAlertParams } from './alert_type_params'; + +export function isEsQueryAlert(searchType: EsQueryAlertParams['searchType']) { + return searchType !== 'searchSource'; +} diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx index 444ba878d67096..253c3ca78b4878 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx @@ -138,7 +138,7 @@ describe('helpers', () => { ]); }); - test('it defaults to a `columnType` of empty string when a column does NOT has a corresponding entry in `columnHeaders`', () => { + test('it defaults to a `columnType` of empty string when a column does NOT have a corresponding entry in `columnHeaders`', () => { const withUnknownColumn: Array<{ id: string; direction: 'asc' | 'desc'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 70d3a81a2f808e..b62a957cfa9276 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5367,8 +5367,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "Cette intégration n'est pas encore activée. Votre administrateur possède les autorisations requises pour l’activer.", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "Contactez votre administrateur", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Ajouter Elastic Agent", - "sharedUXComponents.noDataViews.learnMore": "Envie d'en savoir plus ?", - "sharedUXComponents.noDataViews.readDocumentation": "Lisez les documents", + "sharedUXPackages.noDataViewsPrompt.learnMore": "Envie d'en savoir plus ?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", "sharedUXComponents.pageTemplate.noDataCard.description": "Continuer sans collecter de données", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "Ajouter depuis la bibliothèque", "telemetry.callout.appliesSettingTitle": "Les modifications apportées à ce paramètre s'appliquent dans {allOfKibanaText} et sont enregistrées automatiquement.", @@ -11801,8 +11801,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Cloud Confluence", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "Effectuez des recherches sur le contenu de votre organisation sur le serveur Confluence avec Workplace Search.", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Serveur Confluence", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "Effectuez n'importe quelle recherche en créant votre propre intégration avec Workplace Search.", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "Source d'API personnalisée", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "Effectuez des recherches dans vos fichiers et dossiers stockés sur Dropbox avec Workplace Search.", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "Effectuez des recherches sur vos projets et référentiels sur GitHub avec Workplace Search.", @@ -12839,7 +12837,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "Supprimez les ressources Kibana et Elasticsearch installées par cette intégration.", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote} Impossible d'installer {title}, car des agents actifs utilisent cette intégration. Pour procéder à la désinstallation, supprimez toutes les intégrations {title} de vos stratégies d'agent.", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "Remarque :", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote} L'intégration de {title} est une intégration système. Vous ne pouvez pas la supprimer.", "xpack.fleet.integrations.settings.packageUninstallTitle": "Désinstaller", "xpack.fleet.integrations.settings.packageVersionTitle": "Version de {title}", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "Version installée", @@ -13071,8 +13068,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "Annuler", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "Mettre à niveau {count, plural, one {l'agent} other {{count} agents} =true {tous les agents sélectionnés}}", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "Mettre à niveau l'agent", - "xpack.fleet.upgradeAgents.experimentalLabel": "Expérimental", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "Une modification ou une suppression de la mise à niveau de l'agent peut intervenir dans une version ultérieure. La mise à niveau n'est pas soumise à l'accord de niveau de service du support technique.", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "Erreur lors de la mise à niveau de {count, plural, one {l'agent} other {{count} agents} =true {tous les agents sélectionnés}}", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "{isMixed, select, true {{success} agents sur {total}} other {{isAllAgents, select, true {Tous les agents sélectionnés} other {{success}} }}} mis à niveau", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "{count} agent mis à niveau", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a20feeeccdb1b5..fe7056a5e3ec14 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5469,8 +5469,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "この統合はまだ有効ではありません。管理者にはオンにするために必要なアクセス権があります。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "管理者にお問い合わせください", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Elasticエージェントの追加", - "sharedUXComponents.noDataViews.learnMore": "詳細について", - "sharedUXComponents.noDataViews.readDocumentation": "ドキュメントを読む", + "sharedUXPackages.noDataViewsPrompt.learnMore": "詳細について", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "ドキュメントを読む", "sharedUXComponents.pageTemplate.noDataCard.description": "データを収集せずに続行", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "ライブラリから追加", "telemetry.callout.appliesSettingTitle": "この設定に加えた変更は {allOfKibanaText} に適用され、自動的に保存されます。", @@ -11900,8 +11900,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Confluence Cloud", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "Workplace Searchを使用して、Confluence Serverの組織コンテンツを検索します。", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Confluence Server", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "Workplace Searchを使用して、独自の統合を構築し、項目を検索します。", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "カスタムAPIソース", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "Workplace Searchを使用して、Dropboxに保存されたファイルとフォルダーを検索します。", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "Workplace Searchを使用して、GitHubのプロジェクトとリポジトリを検索します。", @@ -12946,7 +12944,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "この統合によってインストールされたKibanaおよびElasticsearchアセットを削除します。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote} {title}をアンインストールできません。この統合を使用しているアクティブなエージェントがあります。アンインストールするには、エージェントポリシーからすべての{title}統合を削除します。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "注:", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote} {title}統合はシステム統合であるため、削除できません。", "xpack.fleet.integrations.settings.packageUninstallTitle": "アンインストール", "xpack.fleet.integrations.settings.packageVersionTitle": "{title}バージョン", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "インストールされているバージョン", @@ -13178,8 +13175,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "キャンセル", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "{count, plural, other {{count}個のエージェント} =true {すべての選択されたエージェント}}をアップグレード", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "エージェントをアップグレード", - "xpack.fleet.upgradeAgents.experimentalLabel": "実験的", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "アップグレードエージェントは今後のリリースで変更または削除される可能性があり、SLA のサポート対象になりません。", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "{count, plural, other {{count}個のエージェント} =true {すべての選択されたエージェント}}のアップグレードエラー", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "{isMixed, select, true {{success}/{total}個の} other {{isAllAgents, select, true {すべての選択された} other {{success}} }}}エージェントをアップグレードしました", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "{count}個のエージェントをアップグレードしました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a2c33d9a1fae7f..990a113fcd9d64 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5480,8 +5480,8 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "尚未启用此集成。您的管理员具有打开它所需的权限。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "请联系您的管理员", "sharedUXComponents.noDataPage.elasticAgentCard.title": "添加 Elastic 代理", - "sharedUXComponents.noDataViews.learnMore": "希望了解详情?", - "sharedUXComponents.noDataViews.readDocumentation": "阅读文档", + "sharedUXPackages.noDataViewsPrompt.learnMore": "希望了解详情?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "阅读文档", "sharedUXComponents.pageTemplate.noDataCard.description": "继续,而不收集数据", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "从库中添加", "telemetry.callout.appliesSettingTitle": "对此设置的更改将应用到{allOfKibanaText} 且会自动保存。", @@ -11922,8 +11922,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Confluence Cloud", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "通过 Workplace Search 搜索 Confluence Server 上的组织内容。", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Confluence Server", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "通过使用 Workplace Search 构建自己的集成来搜索任何内容。", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "定制 API 源", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "通过 Workplace Search 搜索存储在 Dropbox 上的文件和文件夹。", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "通过 Workplace Search 搜索 GitHub 上的项目和存储库。", @@ -12970,7 +12968,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "移除此集成安装的 Kibana 和 Elasticsearch 资产。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote}{title} 无法卸载,因为存在使用此集成的活动代理。要卸载,请从您的代理策略中移除所有 {title} 集成。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "注意:", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote}{title} 集成是系统集成,无法移除。", "xpack.fleet.integrations.settings.packageUninstallTitle": "卸载", "xpack.fleet.integrations.settings.packageVersionTitle": "{title} 版本", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "已安装版本", @@ -13202,8 +13199,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "取消", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "升级{count, plural, one {代理} other { {count} 个代理} =true {所有选定代理}}", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "升级代理", - "xpack.fleet.upgradeAgents.experimentalLabel": "实验性", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "在未来的版本中可能会更改或移除升级代理,其不受支持 SLA 的约束。", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "升级{count, plural, one {代理} other { {count} 个代理} =true {所有选定代理}}时出错", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "已升级{isMixed, select, true { {success} 个(共 {total} 个)} other {{isAllAgents, select, true {所有选定} other { {success} 个} }}}代理", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "已升级 {count} 个代理", diff --git a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts index 33f5fdc44afcde..3265469bea6406 100644 --- a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts +++ b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts @@ -15,8 +15,8 @@ export const allowedExperimentalValues = Object.freeze({ rulesListDatagrid: true, internalAlertsTable: false, internalShareableComponentsSandbox: false, - ruleTagFilter: false, - ruleStatusFilter: false, + ruleTagFilter: true, + ruleStatusFilter: true, rulesDetailLogs: true, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx index f65f66587ba74c..dad43f666ad0ae 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx @@ -6,16 +6,17 @@ */ import React from 'react'; +import { LogoProps } from '../types'; -const Logo = () => ( +const Logo = (props: LogoProps) => ( x-logo diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts new file mode 100644 index 00000000000000..b00101da6be830 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRuleAggregations } from './use_load_rule_aggregations'; +import { RuleStatus } from '../../types'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +const MOCK_AGGS = { + ruleEnabledStatus: { enabled: 2, disabled: 0 }, + ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }, + ruleMutedStatus: { muted: 0, unmuted: 2 }, + ruleTags: MOCK_TAGS, +}; + +jest.mock('../lib/rule_api', () => ({ + loadRuleAggregations: jest.fn(), +})); + +const { loadRuleAggregations } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadRuleAggregations', () => { + beforeEach(() => { + loadRuleAggregations.mockResolvedValue(MOCK_AGGS); + jest.clearAllMocks(); + }); + + it('should call loadRuleAggregations API and handle result', async () => { + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call loadRuleAggregation API with params and handle result', async () => { + const params = { + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call onError if API fails', async () => { + loadRuleAggregations.mockRejectedValue(''); + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts new file mode 100644 index 00000000000000..75f9e18ec2328c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { useState, useCallback, useMemo } from 'react'; +import { RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; +import { loadRuleAggregations, LoadRuleAggregationsProps } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +type UseLoadRuleAggregationsProps = Omit & { + onError: (message: string) => void; +}; + +export function useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, +}: UseLoadRuleAggregationsProps) { + const { http } = useKibana().services; + + const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( + RuleExecutionStatusValues.reduce>( + (prev: Record, status: string) => ({ + ...prev, + [status]: 0, + }), + {} + ) + ); + + const internalLoadRuleAggregations = useCallback(async () => { + try { + const rulesAggs = await loadRuleAggregations({ + http, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + }); + if (rulesAggs?.ruleExecutionStatus) { + setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); + } + } catch (e) { + onError( + i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage', + { + defaultMessage: 'Unable to load rule status info', + } + ) + ); + } + }, [ + http, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + setRulesStatusesTotal, + ]); + + return useMemo( + () => ({ + loadRuleAggregations: internalLoadRuleAggregations, + rulesStatusesTotal, + setRulesStatusesTotal, + }), + [internalLoadRuleAggregations, rulesStatusesTotal, setRulesStatusesTotal] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts new file mode 100644 index 00000000000000..a309beeca58aa9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts @@ -0,0 +1,378 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRules } from './use_load_rules'; +import { + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '@kbn/alerting-plugin/common'; +import { RuleStatus } from '../../types'; + +jest.mock('../lib/rule_api', () => ({ + loadRules: jest.fn(), +})); + +const { loadRules } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); +const onPage = jest.fn(); + +const mockedRulesData = [ + { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '1s' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 1000000, + }, + { + success: true, + duration: 200000, + }, + { + success: false, + duration: 300000, + }, + ], + calculated_metrics: { + success_ratio: 0.66, + p50: 200000, + p95: 300000, + p99: 300000, + }, + }, + }, + }, + { + id: '2', + name: 'test rule ok', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastDuration: 61000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 100000, + }, + { + success: true, + duration: 500000, + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 0, + p95: 100000, + p99: 500000, + }, + }, + }, + }, + { + id: '3', + name: 'test rule pending', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastDuration: 30234, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [{ success: false, duration: 100 }], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + }, + { + id: '4', + name: 'test rule error', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 122000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.Unknown, + message: 'test', + }, + }, + }, + { + id: '5', + name: 'test rule license error', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, + }, + }, + { + id: '6', + name: 'test rule warning', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'warning', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'test', + }, + }, + }, +]; + +const MOCK_RULE_DATA = { + page: 1, + perPage: 10000, + total: 4, + data: mockedRulesData, +}; + +describe('useLoadRules', () => { + beforeEach(() => { + loadRules.mockResolvedValue(MOCK_RULE_DATA); + jest.clearAllMocks(); + }); + + it('should call loadRules API and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + expect(result.current.initialLoad).toBeTruthy(); + expect(result.current.noData).toBeTruthy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(result.current.initialLoad).toBeFalsy(); + expect(result.current.noData).toBeFalsy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + expect(onPage).toBeCalledTimes(0); + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesState.data).toEqual(expect.arrayContaining(MOCK_RULE_DATA.data)); + expect(result.current.rulesState.totalItemCount).toEqual(MOCK_RULE_DATA.total); + }); + + it('should call loadRules API with params and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + }); + + it('should reset the page if the data is fetched while paged', async () => { + loadRules.mockResolvedValue({ + ...MOCK_RULE_DATA, + data: [], + }); + + const params = { + page: { + index: 1, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(onPage).toHaveBeenCalledWith({ + index: 0, + size: 25, + }); + }); + + it('should call onError if API fails', async () => { + loadRules.mockRejectedValue(''); + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts new file mode 100644 index 00000000000000..4afdfd4f26a725 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useMemo, useCallback, useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import { Rule, Pagination } from '../../types'; +import { loadRules, LoadRulesProps } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +interface RuleState { + isLoading: boolean; + data: Rule[]; + totalItemCount: number; +} + +type UseLoadRulesProps = Omit & { + onPage: (pagination: Pagination) => void; + onError: (message: string) => void; +}; + +interface UseLoadRulesState { + rulesState: RuleState; + noData: boolean; + initialLoad: boolean; +} + +enum ActionTypes { + SET_RULE_STATE = 'SET_RULE_STATE', + SET_LOADING = 'SET_LOADING', + SET_INITIAL_LOAD = 'SET_INITIAL_LOAD', + SET_NO_DATA = 'SET_NO_DATA', +} + +interface Action { + type: ActionTypes; + payload: boolean | RuleState; +} + +const initialState: UseLoadRulesState = { + rulesState: { + isLoading: false, + data: [], + totalItemCount: 0, + }, + noData: true, + initialLoad: true, +}; + +const reducer = (state: UseLoadRulesState, action: Action) => { + const { type, payload } = action; + switch (type) { + case ActionTypes.SET_RULE_STATE: + return { + ...state, + rulesState: payload as RuleState, + }; + case ActionTypes.SET_LOADING: + return { + ...state, + rulesState: { + ...state.rulesState, + isLoading: payload as boolean, + }, + }; + case ActionTypes.SET_INITIAL_LOAD: + return { + ...state, + initialLoad: payload as boolean, + }; + case ActionTypes.SET_NO_DATA: + return { + ...state, + noData: payload as boolean, + }; + default: + return state; + } +}; + +export function useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + onPage, + onError, +}: UseLoadRulesProps) { + const { http } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const setRulesState = useCallback( + (rulesState: RuleState) => { + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: rulesState, + }); + }, + [dispatch] + ); + + const internalLoadRules = useCallback(async () => { + dispatch({ type: ActionTypes.SET_LOADING, payload: true }); + + try { + const rulesResponse = await loadRules({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + }); + + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: { + isLoading: false, + data: rulesResponse.data, + totalItemCount: rulesResponse.total, + }, + }); + + if (!rulesResponse.data?.length && page.index > 0) { + onPage({ ...page, index: 0 }); + } + + const isFilterApplied = !( + isEmpty(searchText) && + isEmpty(typesFilter) && + isEmpty(actionTypesFilter) && + isEmpty(ruleExecutionStatusesFilter) && + isEmpty(ruleStatusesFilter) && + isEmpty(tagsFilter) + ); + + dispatch({ + type: ActionTypes.SET_NO_DATA, + payload: rulesResponse.data.length === 0 && !isFilterApplied, + }); + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', { + defaultMessage: 'Unable to load rules', + }) + ); + dispatch({ type: ActionTypes.SET_LOADING, payload: false }); + } + dispatch({ type: ActionTypes.SET_INITIAL_LOAD, payload: false }); + }, [ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + dispatch, + onPage, + onError, + ]); + + return useMemo( + () => ({ + rulesState: state.rulesState, + noData: state.noData, + initialLoad: state.initialLoad, + loadRules: internalLoadRules, + setRulesState, + }), + [state, setRulesState, internalLoadRules] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts new file mode 100644 index 00000000000000..8973d869e0724a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadTags } from './use_load_tags'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +jest.mock('../lib/rule_api', () => ({ + loadRuleTags: jest.fn(), +})); + +const { loadRuleTags } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadTags', () => { + beforeEach(() => { + loadRuleTags.mockResolvedValue({ + ruleTags: MOCK_TAGS, + }); + jest.clearAllMocks(); + }); + + it('should call loadRuleTags API and handle result', async () => { + const { result, waitForNextUpdate } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + await waitForNextUpdate(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(result.current.tags).toEqual(MOCK_TAGS); + }); + + it('should call onError if API fails', async () => { + loadRuleTags.mockRejectedValue(''); + + const { result } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(onError).toBeCalled(); + expect(result.current.tags).toEqual([]); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts new file mode 100644 index 00000000000000..3357f43a012f12 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { useState, useCallback, useMemo } from 'react'; +import { loadRuleTags } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +interface UseLoadTagsProps { + onError: (message: string) => void; +} + +export function useLoadTags(props: UseLoadTagsProps) { + const { onError } = props; + const { http } = useKibana().services; + const [tags, setTags] = useState([]); + + const internalLoadTags = useCallback(async () => { + try { + const ruleTagsAggs = await loadRuleTags({ http }); + if (ruleTagsAggs?.ruleTags) { + setTags(ruleTagsAggs.ruleTags); + } + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { + defaultMessage: 'Unable to load rule tags', + }) + ); + } + }, [http, setTags, onError]); + + return useMemo( + () => ({ + tags, + loadTags: internalLoadTags, + setTags, + }), + [tags, internalLoadTags, setTags] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx index 4af95523dce293..ba45800e49bcb2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { getRuleEventLogListLazy } from '../../../common/get_rule_event_log_list'; export const RuleEventLogListSandbox = () => { @@ -39,5 +40,5 @@ export const RuleEventLogListSandbox = () => { }), }; - return getRuleEventLogListLazy(props); + return
{getRuleEventLogListLazy(props)}
; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx new file mode 100644 index 00000000000000..7702b914cfd362 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { getRulesListLazy } from '../../../common/get_rules_list'; + +const style = { + flex: 1, +}; + +export const RulesListSandbox = () => { + return
{getRulesListLazy()}
; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx index af5a05acdf19ad..018f0a8794c33b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx @@ -11,6 +11,7 @@ import { RuleTagFilterSandbox } from './rule_tag_filter_sandbox'; import { RuleStatusFilterSandbox } from './rule_status_filter_sandbox'; import { RuleTagBadgeSandbox } from './rule_tag_badge_sandbox'; import { RuleEventLogListSandbox } from './rule_event_log_list_sandbox'; +import { RulesListSandbox } from './rules_list_sandbox'; export const InternalShareableComponentsSandbox: React.FC<{}> = () => { return ( @@ -19,6 +20,7 @@ export const InternalShareableComponentsSandbox: React.FC<{}> = () => { + ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts index 1df61774436572..5df7cfc374f89b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts @@ -44,6 +44,16 @@ export async function loadRuleTags({ http }: { http: HttpSetup }): Promise { +}: LoadRuleAggregationsProps): Promise { const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index d0e7728498c5bd..64d6b18b7ca5c7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -7,6 +7,7 @@ export { alertingFrameworkHealth } from './health'; export { mapFiltersToKql } from './map_filters_to_kql'; +export type { LoadRuleAggregationsProps } from './aggregate'; export { loadRuleAggregations, loadRuleTags } from './aggregate'; export { createRule } from './create'; export { deleteRules } from './delete'; @@ -17,6 +18,7 @@ export { loadRuleSummary } from './rule_summary'; export { muteAlertInstance } from './mute_alert'; export { muteRule, muteRules } from './mute'; export { loadRuleTypes } from './rule_types'; +export type { LoadRulesProps } from './rules'; export { loadRules } from './rules'; export { loadRuleState } from './state'; export type { LoadExecutionLogAggregationsProps } from './load_execution_log_aggregations'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts index 6e527989cc91f9..3db1cb8b0214d3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts @@ -11,6 +11,18 @@ import { Rule, Pagination, Sorting, RuleStatus } from '../../../types'; import { mapFiltersToKql } from './map_filters_to_kql'; import { transformRule } from './common_transformations'; +export interface LoadRulesProps { + http: HttpSetup; + page: Pagination; + searchText?: string; + typesFilter?: string[]; + actionTypesFilter?: string[]; + tagsFilter?: string[]; + ruleExecutionStatusesFilter?: string[]; + ruleStatusesFilter?: RuleStatus[]; + sort?: Sorting; +} + const rewriteResponseRes = (results: Array>): Rule[] => { return results.map((item) => transformRule(item)); }; @@ -25,17 +37,7 @@ export async function loadRules({ ruleStatusesFilter, tagsFilter, sort = { field: 'name', direction: 'asc' }, -}: { - http: HttpSetup; - page: Pagination; - searchText?: string; - typesFilter?: string[]; - actionTypesFilter?: string[]; - tagsFilter?: string[]; - ruleExecutionStatusesFilter?: string[]; - ruleStatusesFilter?: RuleStatus[]; - sort?: Sorting; -}): Promise<{ +}: LoadRulesProps): Promise<{ page: number; perPage: number; total: number; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx index 979630d2a5a99d..bd2ef041535f33 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx @@ -44,3 +44,6 @@ export const RuleTagBadge = suspendedComponentWithProps( export const RuleEventLogList = suspendedComponentWithProps( lazy(() => import('./rule_details/components/rule_event_log_list')) ); +export const RulesList = suspendedComponentWithProps( + lazy(() => import('./rules_list/components/rules_list')) +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 1bca80a08c936a..6da565b13d91e1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -724,10 +724,10 @@ export const RuleForm = ({ name="interval" data-test-subj="intervalInput" onChange={(e) => { - const interval = - e.target.value !== '' ? parseInt(e.target.value, 10) : undefined; + const value = e.target.value; + const interval = value !== '' ? parseInt(value, 10) : undefined; setRuleInterval(interval); - setScheduleProperty('interval', `${e.target.value}${ruleIntervalUnit}`); + setScheduleProperty('interval', `${value}${ruleIntervalUnit}`); }} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx index 4c23aa0dda40d9..992c4df4e57982 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_notify_when.tsx @@ -28,7 +28,7 @@ import { RuleNotifyWhenType } from '../../../types'; const DEFAULT_NOTIFY_WHEN_VALUE: RuleNotifyWhenType = 'onActionGroupChange'; -const NOTIFY_WHEN_OPTIONS: Array> = [ +export const NOTIFY_WHEN_OPTIONS: Array> = [ { value: 'onActionGroupChange', inputDisplay: i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx index a136413d53e42c..38d1a62de699a4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { ActionType } from '../../../../types'; interface ActionTypeFilterProps { @@ -29,47 +29,52 @@ export const ActionTypeFilter: React.FunctionComponent = // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedValues]); + const onClick = useCallback( + (item: ActionType) => { + return () => { + const isPreviouslyChecked = selectedValues.includes(item.id); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.id)); + } else { + setSelectedValues(selectedValues.concat(item.id)); + } + }; + }, + [selectedValues, setSelectedValues] + ); + return ( - - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="actionTypeFilterButton" + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="actionTypeFilterButton" + > + + + } + > +
+ {actionTypes.map((item) => ( + - - - } - > -
- {actionTypes.map((item) => ( - { - const isPreviouslyChecked = selectedValues.includes(item.id); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.id)); - } else { - setSelectedValues(selectedValues.concat(item.id)); - } - }} - checked={selectedValues.includes(item.id) ? 'on' : undefined} - data-test-subj={`actionType${item.id}FilterOption`} - > - {item.name} - - ))} -
- - + {item.name} +
+ ))} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx index 9acb8489fa09ac..e5bb7ffd1b0e42 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx @@ -5,15 +5,9 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiHealth, -} from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiHealth } from '@elastic/eui'; import { RuleExecutionStatuses, RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; import { rulesStatusesTranslationsMapping } from '../translations'; @@ -22,6 +16,8 @@ interface RuleExecutionStatusFilterProps { onChange?: (selectedRuleStatusesIds: string[]) => void; } +const sortedRuleExecutionStatusValues = [...RuleExecutionStatusValues].sort(); + export const RuleExecutionStatusFilter: React.FunctionComponent = ({ selectedStatuses, onChange, @@ -29,6 +25,14 @@ export const RuleExecutionStatusFilter: React.FunctionComponent(selectedStatuses); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onTogglePopover = useCallback(() => { + setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen); + }, [setIsPopoverOpen]); + + const onClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, [setIsPopoverOpen]); + useEffect(() => { if (onChange) { onChange(selectedValues); @@ -41,51 +45,49 @@ export const RuleExecutionStatusFilter: React.FunctionComponent - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="ruleExecutionStatusFilterButton" - > - - - } - > -
- {[...RuleExecutionStatusValues].sort().map((item: RuleExecutionStatuses) => { - const healthColor = getHealthColor(item); - return ( - { - const isPreviouslyChecked = selectedValues.includes(item); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item)); - } else { - setSelectedValues(selectedValues.concat(item)); - } - }} - checked={selectedValues.includes(item) ? 'on' : undefined} - data-test-subj={`ruleExecutionStatus${item}FilterOption`} - > - {rulesStatusesTranslationsMapping[item]} - - ); - })} -
-
-
+ 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={onTogglePopover} + data-test-subj="ruleExecutionStatusFilterButton" + > + + + } + > +
+ {sortedRuleExecutionStatusValues.map((item: RuleExecutionStatuses) => { + const healthColor = getHealthColor(item); + return ( + { + const isPreviouslyChecked = selectedValues.includes(item); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item)); + } else { + setSelectedValues(selectedValues.concat(item)); + } + }} + checked={selectedValues.includes(item) ? 'on' : undefined} + data-test-subj={`ruleExecutionStatus${item}FilterOption`} + > + {rulesStatusesTranslationsMapping[item]} + + ); + })} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx index 7c6a71e893f96e..194bf86030e56e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx @@ -33,7 +33,7 @@ import { parseInterval } from '../../../../../common'; import { Rule } from '../../../../types'; -type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; +export type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; const SNOOZE_END_TIME_FORMAT = 'LL @ LT'; type DropdownRuleRecord = Pick; @@ -48,6 +48,7 @@ export interface ComponentOpts { isEditable: boolean; previousSnoozeInterval?: string | null; direction?: 'column' | 'row'; + hideSnoozeOption?: boolean; } const COMMON_SNOOZE_TIMES: Array<[number, SnoozeUnit]> = [ @@ -58,9 +59,9 @@ const COMMON_SNOOZE_TIMES: Array<[number, SnoozeUnit]> = [ ]; const PREV_SNOOZE_INTERVAL_KEY = 'triggersActionsUi_previousSnoozeInterval'; -const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: string) => void] = ( - propsInterval -) => { +export const usePreviousSnoozeInterval: ( + p?: string | null +) => [string | null, (n: string) => void] = (propsInterval) => { const intervalFromStorage = localStorage.getItem(PREV_SNOOZE_INTERVAL_KEY); const usePropsInterval = typeof propsInterval !== 'undefined'; const interval = usePropsInterval ? propsInterval : intervalFromStorage; @@ -74,7 +75,7 @@ const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: stri return [previousSnoozeInterval, storeAndSetPreviousSnoozeInterval]; }; -const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => +export const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => Boolean( (rule.isSnoozedUntil && new Date(rule.isSnoozedUntil).getTime() > Date.now()) || rule.muteAll ); @@ -88,6 +89,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ unsnoozeRule, isEditable, previousSnoozeInterval: propsPreviousSnoozeInterval, + hideSnoozeOption = false, direction = 'column', }: ComponentOpts) => { const [isEnabled, setIsEnabled] = useState(rule.enabled); @@ -224,6 +226,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ isSnoozed={isSnoozed} snoozeEndTime={rule.isSnoozedUntil} previousSnoozeInterval={previousSnoozeInterval} + hideSnoozeOption={hideSnoozeOption} /> ) : ( @@ -245,6 +248,7 @@ interface RuleStatusMenuProps { isSnoozed: boolean; snoozeEndTime?: Date | null; previousSnoozeInterval: string | null; + hideSnoozeOption?: boolean; } const RuleStatusMenu: React.FunctionComponent = ({ @@ -255,6 +259,7 @@ const RuleStatusMenu: React.FunctionComponent = ({ isSnoozed, snoozeEndTime, previousSnoozeInterval, + hideSnoozeOption = false, }) => { const enableRule = useCallback(() => { if (isSnoozed) { @@ -290,6 +295,44 @@ const RuleStatusMenu: React.FunctionComponent = ({ ); } + const getSnoozeMenuItem = () => { + if (!hideSnoozeOption) { + return [ + { + name: snoozeButtonTitle, + icon: isEnabled && isSnoozed ? 'check' : 'empty', + panel: 1, + disabled: !isEnabled, + 'data-test-subj': 'statusDropdownSnoozeItem', + }, + ]; + } + return []; + }; + + const getSnoozePanel = () => { + if (!hideSnoozeOption) { + return [ + { + id: 1, + width: 360, + title: SNOOZE, + content: ( + + + + ), + }, + ]; + } + return []; + }; + const panels = [ { id: 0, @@ -307,28 +350,10 @@ const RuleStatusMenu: React.FunctionComponent = ({ onClick: disableRule, 'data-test-subj': 'statusDropdownDisabledItem', }, - { - name: snoozeButtonTitle, - icon: isEnabled && isSnoozed ? 'check' : 'empty', - panel: 1, - disabled: !isEnabled, - 'data-test-subj': 'statusDropdownSnoozeItem', - }, + ...getSnoozeMenuItem(), ], }, - { - id: 1, - width: 360, - title: SNOOZE, - content: ( - - ), - }, + ...getSnoozePanel(), ]; return ; @@ -336,13 +361,15 @@ const RuleStatusMenu: React.FunctionComponent = ({ interface SnoozePanelProps { interval?: string; + isLoading?: boolean; applySnooze: (value: number | -1, unit?: SnoozeUnit) => void; showCancel: boolean; previousSnoozeInterval: string | null; } -const SnoozePanel: React.FunctionComponent = ({ +export const SnoozePanel: React.FunctionComponent = ({ interval = '3d', + isLoading = false, applySnooze, showCancel, previousSnoozeInterval, @@ -394,9 +421,9 @@ const SnoozePanel: React.FunctionComponent = ({ ); return ( - + <> - + = ({ /> - + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.applySnooze', { defaultMessage: 'Apply', })} @@ -471,7 +502,12 @@ const SnoozePanel: React.FunctionComponent = ({ - + Cancel snooze @@ -479,11 +515,11 @@ const SnoozePanel: React.FunctionComponent = ({ )} - + ); }; -const futureTimeToInterval = (time?: Date | null) => { +export const futureTimeToInterval = (time?: Date | null) => { if (!time) return; const relativeTime = moment(time).locale('en').fromNow(true); const [valueStr, unitStr] = relativeTime.split(' '); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx index f1f2957f9cadaa..a7d3bdfb8e2e0d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiFilterButton, EuiSelectableListItem } from '@elastic/eui'; import { RuleStatusFilter } from './rule_status_filter'; const onChangeMock = jest.fn(); -describe('rule_state_filter', () => { +describe('RuleStatusFilter', () => { beforeEach(() => { onChangeMock.mockReset(); }); @@ -22,7 +22,7 @@ describe('rule_state_filter', () => { ); - expect(wrapper.find(EuiFilterSelectItem).exists()).toBeFalsy(); + expect(wrapper.find(EuiSelectableListItem).exists()).toBeFalsy(); expect(wrapper.find(EuiFilterButton).exists()).toBeTruthy(); expect(wrapper.find('.euiNotificationBadge').text()).toEqual('0'); @@ -37,7 +37,7 @@ describe('rule_state_filter', () => { wrapper.find(EuiFilterButton).simulate('click'); - const statusItems = wrapper.find(EuiFilterSelectItem); + const statusItems = wrapper.find(EuiSelectableListItem); expect(statusItems.length).toEqual(3); }); @@ -48,17 +48,17 @@ describe('rule_state_filter', () => { wrapper.find(EuiFilterButton).simulate('click'); - wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + wrapper.find(EuiSelectableListItem).at(0).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith(['enabled']); wrapper.setProps({ selectedStatuses: ['enabled'], }); - wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + wrapper.find(EuiSelectableListItem).at(0).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith([]); - wrapper.find(EuiFilterSelectItem).at(1).simulate('click'); + wrapper.find(EuiSelectableListItem).at(1).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith(['enabled', 'disabled']); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx index 6d286ec6d09d79..f26b3f54c587ed 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx @@ -6,7 +6,13 @@ */ import React, { useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui'; +import { + EuiFilterButton, + EuiPopover, + EuiFilterGroup, + EuiSelectableListItem, + EuiButtonEmpty, +} from '@elastic/eui'; import { RuleStatus } from '../../../../types'; const statuses: RuleStatus[] = ['enabled', 'disabled', 'snoozed']; @@ -53,6 +59,24 @@ export const RuleStatusFilter = (props: RuleStatusFilterProps) => { setIsPopoverOpen((prevIsOpen) => !prevIsOpen); }, [setIsPopoverOpen]); + const renderClearAll = () => { + return ( +
+ onChange([])} + > + Clear all + +
+ ); + }; + return ( { > } @@ -77,7 +101,7 @@ export const RuleStatusFilter = (props: RuleStatusFilterProps) => {
{statuses.map((status) => { return ( - { checked={selectedStatuses.includes(status) ? 'on' : undefined} > {status} - + ); })} + {renderClearAll()}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx index 636bcaf1acb22d..47b93ff19c6ea3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx @@ -9,7 +9,6 @@ import React, { useMemo, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSelectable, - EuiFilterGroup, EuiFilterButton, EuiPopover, EuiSelectableProps, @@ -103,29 +102,32 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => { }; return ( - - - - {(list, search) => ( - <> - {search} - - {list} - - )} - - - + + + {(list, search) => ( + <> + {search} + + {list} + + )} + + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 7827033138fbba..893d6cf7bc5ad4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -365,7 +365,7 @@ describe('Update Api Key', () => { fireEvent.click(screen.getByText('Update')); }); expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); - expect(loadRules).toHaveBeenCalledTimes(2); + expect(loadRules).toHaveBeenCalledTimes(3); expect(screen.queryByText("You can't recover the old API key")).not.toBeInTheDocument(); expect(addSuccess).toHaveBeenCalledWith('API key has been updated'); }); @@ -390,7 +390,7 @@ describe('Update Api Key', () => { fireEvent.click(screen.getByText('Update')); }); expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); - expect(loadRules).toHaveBeenCalledTimes(2); + expect(loadRules).toHaveBeenCalledTimes(3); expect( screen.queryByText('You will not be able to recover the old API key') ).not.toBeInTheDocument(); @@ -514,7 +514,6 @@ describe('rules_list component with items', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; wrapper = mountWithIntl(); - await act(async () => { await nextTick(); wrapper.update(); @@ -561,7 +560,7 @@ describe('rules_list component with items', () => { .simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe('Start time of the last run.'); @@ -580,7 +579,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -605,7 +604,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="rulesTableCell-durationTooltip"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -627,7 +626,7 @@ describe('rules_list component with items', () => { wrapper.find('EuiButtonEmpty[data-test-subj="ruleStatus-error-license-fix"]').length ).toEqual(1); - expect(wrapper.find('[data-test-subj="refreshRulesButton"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="rulesListAutoRefresh"]').exists()).toBeTruthy(); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').first().text()).toEqual( 'Error' @@ -724,7 +723,7 @@ describe('rules_list component with items', () => { .first() .simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); // Percentile Selection @@ -740,7 +739,7 @@ describe('rules_list component with items', () => { // Select P95 percentileOptions.at(1).simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect( @@ -795,18 +794,6 @@ describe('rules_list component with items', () => { jest.clearAllMocks(); }); - it('loads rules when refresh button is clicked', async () => { - await setup(); - wrapper.find('[data-test-subj="refreshRulesButton"]').first().simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(loadRules).toHaveBeenCalled(); - }); - it('renders license errors and manage license modal on click', async () => { global.open = jest.fn(); await setup(); @@ -854,7 +841,7 @@ describe('rules_list component with items', () => { it('sorts rules when clicking the status control column', async () => { await setup(); wrapper - .find('[data-test-subj="tableHeaderCell_enabled_8"] .euiTableHeaderButton') + .find('[data-test-subj="tableHeaderCell_enabled_9"] .euiTableHeaderButton') .first() .simulate('click'); @@ -923,21 +910,37 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].ruleStatusesFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterButton"] button').simulate('click'); wrapper.find('[data-test-subj="ruleStatusFilterOption-enabled"]').first().simulate('click'); - expect(loadRules.mock.calls[1][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[2][0].ruleStatusesFilter).toEqual(['enabled', 'snoozed']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled', 'snoozed'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[3][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); }); it('does not render the tag filter is the feature flag is off', async () => { @@ -956,7 +959,11 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].tagsFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleTagFilterButton"] button').simulate('click'); @@ -967,11 +974,19 @@ describe('rules_list component with items', () => { tagFilterListItems.at(0).simulate('click'); - expect(loadRules.mock.calls[1][0].tagsFilter).toEqual(['a']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a'], + }) + ); tagFilterListItems.at(1).simulate('click'); - expect(loadRules.mock.calls[2][0].tagsFilter).toEqual(['a', 'b']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a', 'b'], + }) + ); }); }); @@ -1255,4 +1270,21 @@ describe('rules_list with disabled items', () => { wrapper.find('EuiIconTip[data-test-subj="ruleDisabledByLicenseTooltip"]').props().content ).toEqual('This rule type requires a Platinum license.'); }); + + it('clicking the notify badge shows the snooze panel', async () => { + await setup(); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeFalsy(); + + wrapper + .find('[data-test-subj="rulesTableCell-rulesListNotify"]') + .first() + .simulate('mouseenter'); + + expect(wrapper.find('[data-test-subj="rulesListNotifyBadge"]').exists()).toBeTruthy(); + + wrapper.find('[data-test-subj="rulesListNotifyBadge"]').first().simulate('click'); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index 9c3f1415e6641c..b8afb2d3124ef3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -8,49 +8,36 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { i18n } from '@kbn/i18n'; -import { capitalize, sortBy } from 'lodash'; import moment from 'moment'; +import { capitalize, sortBy } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useEffect, useState, useMemo, ReactNode, useCallback } from 'react'; +import React, { useEffect, useState, ReactNode, useCallback, useMemo } from 'react'; import { - EuiBasicTable, EuiButton, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, - EuiIconTip, + EuiFilterGroup, EuiSpacer, EuiLink, EuiEmptyPrompt, - EuiButtonEmpty, EuiHealth, EuiText, - EuiToolTip, EuiTableSortingType, EuiButtonIcon, EuiHorizontalRule, EuiSelectableOption, EuiIcon, - EuiScreenReaderOnly, - RIGHT_ALIGNMENT, EuiDescriptionList, - EuiTableFieldDataColumnType, - EuiTableComputedColumnType, - EuiTableActionsColumnType, EuiCallOut, } from '@elastic/eui'; import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; import { useHistory } from 'react-router-dom'; -import { isEmpty } from 'lodash'; import { RuleExecutionStatus, - RuleExecutionStatusValues, ALERTS_FEATURE_ID, RuleExecutionStatusErrorReasons, - formatDuration, - parseDuration, - MONITORING_HISTORY_LIMIT, } from '@kbn/alerting-plugin/common'; import { ActionType, @@ -69,11 +56,8 @@ import { RuleQuickEditButtonsWithApi as RuleQuickEditButtons } from '../../commo import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed_item_actions'; import { TypeFilter } from './type_filter'; import { ActionTypeFilter } from './action_type_filter'; -import { RuleExecutionStatusFilter, getHealthColor } from './rule_execution_status_filter'; +import { RuleExecutionStatusFilter } from './rule_execution_status_filter'; import { - loadRules, - loadRuleAggregations, - loadRuleTags, loadRuleTypes, disableRule, enableRule, @@ -87,23 +71,21 @@ import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capab import { routeToRuleDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; import { EmptyPrompt } from '../../../components/prompts/empty_prompt'; -import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; +import { ALERT_STATUS_LICENSE_ERROR } from '../translations'; import { useKibana } from '../../../../common/lib/kibana'; import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants'; import './rules_list.scss'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import { ManageLicenseModal } from './manage_license_modal'; -import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; -import { RuleStatusDropdown } from './rule_status_dropdown'; -import { RuleTagBadge } from './rule_tag_badge'; -import { PercentileSelectablePopover } from './percentile_selectable_popover'; -import { RuleDurationFormat } from './rule_duration_format'; -import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; -import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; import { RuleTagFilter } from './rule_tag_filter'; import { RuleStatusFilter } from './rule_status_filter'; import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; +import { useLoadRules } from '../../../hooks/use_load_rules'; +import { useLoadTags } from '../../../hooks/use_load_tags'; +import { useLoadRuleAggregations } from '../../../hooks/use_load_rule_aggregations'; +import { RulesListTable, convertRulesToTableItems } from './rules_list_table'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; const ENTER_KEY = 13; @@ -113,17 +95,6 @@ interface RuleTypeState { isInitialized: boolean; data: RuleTypeIndex; } -interface RuleState { - isLoading: boolean; - data: Rule[]; - totalItemCount: number; -} - -const percentileOrdinals = { - [Percentiles.P50]: '50th', - [Percentiles.P95]: '95th', - [Percentiles.P99]: '99th', -}; export const percentileFields = { [Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50', @@ -149,8 +120,6 @@ export const RulesList: React.FunctionComponent = () => { } = useKibana().services; const canExecuteActions = hasExecuteActionsCapability(capabilities); - const [initialLoad, setInitialLoad] = useState(true); - const [noData, setNoData] = useState(true); const [config, setConfig] = useState({ isUsingSecurity: false }); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -162,16 +131,15 @@ export const RulesList: React.FunctionComponent = () => { const [actionTypesFilter, setActionTypesFilter] = useState([]); const [ruleExecutionStatusesFilter, setRuleExecutionStatusesFilter] = useState([]); const [ruleStatusesFilter, setRuleStatusesFilter] = useState([]); - const [tags, setTags] = useState([]); const [tagsFilter, setTagsFilter] = useState([]); const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); - const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>( {} ); const [showErrors, setShowErrors] = useState(false); + const [lastUpdate, setLastUpdate] = useState(''); const isRuleTagFilterEnabled = getIsExperimentalFeatureEnabled('ruleTagFilter'); const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter'); @@ -185,13 +153,6 @@ export const RulesList: React.FunctionComponent = () => { const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); - const selectedPercentile = useMemo(() => { - const selectedOption = percentileOptions.find((option) => option.checked === 'on'); - if (selectedOption) { - return Percentiles[selectedOption.key as Percentiles]; - } - }, [percentileOptions]); - const [sort, setSort] = useState['sort']>({ field: 'name', direction: 'asc', @@ -200,27 +161,52 @@ export const RulesList: React.FunctionComponent = () => { licenseType: string; ruleTypeId: string; } | null>(null); - const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( - RuleExecutionStatusValues.reduce( - (prev: Record, status: string) => - ({ - ...prev, - [status]: 0, - } as Record), - {} - ) - ); const [ruleTypesState, setRuleTypesState] = useState({ isLoading: false, isInitialized: false, data: new Map(), }); - const [rulesState, setRulesState] = useState({ - isLoading: false, - data: [], - totalItemCount: 0, - }); + const [rulesToDelete, setRulesToDelete] = useState([]); + + const hasAnyAuthorizedRuleType = useMemo(() => { + return ruleTypesState.isInitialized && ruleTypesState.data.size > 0; + }, [ruleTypesState]); + + const onError = useCallback( + (message: string) => { + toasts.addDanger(message); + }, + [toasts] + ); + + const { rulesState, setRulesState, loadRules, noData, initialLoad } = useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + onPage: setPage, + onError, + }); + + const { tags, loadTags } = useLoadTags({ + onError, + }); + + const { loadRuleAggregations, rulesStatusesTotal } = useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + }); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); const onRuleEdit = (ruleItem: RuleTableItem) => { setEditFlyoutVisibility(true); @@ -230,20 +216,30 @@ export const RulesList: React.FunctionComponent = () => { const isRuleTypeEditableInContext = (ruleTypeId: string) => ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; - useEffect(() => { - loadRulesData(); + const loadData = useCallback(async () => { + if (!ruleTypesState || !hasAnyAuthorizedRuleType) { + return; + } + await loadRules(); + await loadRuleAggregations(); + if (isRuleStatusFilterEnabled) { + await loadTags(); + } + setLastUpdate(moment().format()); }, [ + loadRules, + loadTags, + loadRuleAggregations, + setLastUpdate, + isRuleStatusFilterEnabled, + hasAnyAuthorizedRuleType, ruleTypesState, - page, - searchText, - percentileOptions, - JSON.stringify(typesFilter), - JSON.stringify(actionTypesFilter), - JSON.stringify(ruleExecutionStatusesFilter), - JSON.stringify(ruleStatusesFilter), - JSON.stringify(tagsFilter), ]); + useEffect(() => { + loadData(); + }, [loadData, percentileOptions]); + useEffect(() => { (async () => { try { @@ -289,218 +285,6 @@ export const RulesList: React.FunctionComponent = () => { })(); }, []); - async function loadRulesData() { - const hasAnyAuthorizedRuleType = ruleTypesState.isInitialized && ruleTypesState.data.size > 0; - if (hasAnyAuthorizedRuleType) { - setRulesState({ ...rulesState, isLoading: true }); - try { - const rulesResponse = await loadRules({ - http, - page, - searchText, - typesFilter, - actionTypesFilter, - ruleExecutionStatusesFilter, - ruleStatusesFilter, - tagsFilter, - sort, - }); - await loadRuleTagsAggs(); - await loadRuleAggs(); - setRulesState({ - isLoading: false, - data: rulesResponse.data, - totalItemCount: rulesResponse.total, - }); - - if (!rulesResponse.data?.length && page.index > 0) { - setPage({ ...page, index: 0 }); - } - - const isFilterApplied = !( - isEmpty(searchText) && - isEmpty(typesFilter) && - isEmpty(actionTypesFilter) && - isEmpty(ruleExecutionStatusesFilter) && - isEmpty(ruleStatusesFilter) && - isEmpty(tagsFilter) - ); - - setNoData(rulesResponse.data.length === 0 && !isFilterApplied); - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', - { - defaultMessage: 'Unable to load rules', - } - ), - }); - setRulesState({ ...rulesState, isLoading: false }); - } - setInitialLoad(false); - } - } - - async function loadRuleAggs() { - try { - const rulesAggs = await loadRuleAggregations({ - http, - searchText, - typesFilter, - actionTypesFilter, - ruleExecutionStatusesFilter, - ruleStatusesFilter, - tagsFilter, - }); - if (rulesAggs?.ruleExecutionStatus) { - setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); - } - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage', - { - defaultMessage: 'Unable to load rule status info', - } - ), - }); - } - } - - async function loadRuleTagsAggs() { - if (!isRuleTagFilterEnabled) { - return; - } - try { - const ruleTagsAggs = await loadRuleTags({ http }); - if (ruleTagsAggs?.ruleTags) { - setTags(ruleTagsAggs.ruleTags); - } - } catch (e) { - toasts.addDanger({ - title: i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { - defaultMessage: 'Unable to load rule tags', - }), - }); - } - } - - const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, item: RuleTableItem) => { - return ( - await disableRule({ http, id: item.id })} - enableRule={async () => await enableRule({ http, id: item.id })} - snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { - await snoozeRule({ http, id: item.id, snoozeEndTime }); - }} - unsnoozeRule={async () => await unsnoozeRule({ http, id: item.id })} - rule={item} - onRuleChanged={() => loadRulesData()} - isEditable={item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)} - /> - ); - }; - - const renderRuleExecutionStatus = (executionStatus: RuleExecutionStatus, item: RuleTableItem) => { - const healthColor = getHealthColor(executionStatus.status); - const tooltipMessage = - executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; - const isLicenseError = - executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; - const statusMessage = isLicenseError - ? ALERT_STATUS_LICENSE_ERROR - : rulesStatusesTranslationsMapping[executionStatus.status]; - - const health = ( - - {statusMessage} - - ); - - const healthWithTooltip = tooltipMessage ? ( - - {health} - - ) : ( - health - ); - - return ( - - {healthWithTooltip} - {isLicenseError && ( - - - setManageLicenseModalOpts({ - licenseType: ruleTypesState.data.get(item.ruleTypeId)?.minimumLicenseRequired!, - ruleTypeId: item.ruleTypeId, - }) - } - > - - - - )} - - ); - }; - - const renderPercentileColumnName = () => { - return ( - - - - {selectedPercentile}  - - - - - - ); - }; - - const renderPercentileCellValue = (value: number) => { - return ( - - - - ); - }; - - const getPercentileColumn = () => { - return { - mobileOptions: { header: false }, - field: percentileFields[selectedPercentile!], - width: '16%', - name: renderPercentileColumnName(), - 'data-test-subj': 'rulesTableCell-ruleExecutionPercentile', - sortable: true, - truncateText: false, - render: renderPercentileCellValue, - }; - }; - const buildErrorListItems = (_executionStatus: RuleExecutionStatus) => { const hasErrorMessage = _executionStatus.status === 'error'; const errorMessage = _executionStatus?.error?.message; @@ -563,383 +347,6 @@ export const RulesList: React.FunctionComponent = () => { }); }, [showErrors, rulesState]); - const getRulesTableColumns = (): Array< - | EuiTableFieldDataColumnType - | EuiTableComputedColumnType - | EuiTableActionsColumnType - > => { - return [ - { - field: 'name', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle', - { defaultMessage: 'Name' } - ), - sortable: true, - truncateText: true, - width: '30%', - 'data-test-subj': 'rulesTableCell-name', - render: (name: string, rule: RuleTableItem) => { - const ruleType = ruleTypesState.data.get(rule.ruleTypeId); - const checkEnabledResult = checkRuleTypeEnabled(ruleType); - const link = ( - <> - - - - - { - history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); - }} - > - {name} - - - - {!checkEnabledResult.isEnabled && ( - - )} - - - - - - {rule.ruleType} - - - - - ); - return <>{link}; - }, - }, - { - field: 'tags', - name: '', - sortable: false, - width: '50px', - 'data-test-subj': 'rulesTableCell-tagsPopover', - render: (ruleTags: string[], item: RuleTableItem) => { - return ruleTags.length > 0 ? ( - setTagPopoverOpenIndex(item.index)} - onClose={() => setTagPopoverOpenIndex(-1)} - /> - ) : null; - }, - }, - { - field: 'executionStatus.lastExecutionDate', - name: ( - - - Last run{' '} - - - - ), - sortable: true, - width: '15%', - 'data-test-subj': 'rulesTableCell-lastExecutionDate', - render: (date: Date) => { - if (date) { - return ( - <> - - - {moment(date).format('MMM D, YYYY HH:mm:ssa')} - - - - {moment(date).fromNow()} - - - - - ); - } - }, - }, - { - field: 'schedule.interval', - width: '6%', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle', - { defaultMessage: 'Interval' } - ), - sortable: false, - truncateText: false, - 'data-test-subj': 'rulesTableCell-interval', - render: (interval: string, item: RuleTableItem) => { - const durationString = formatDuration(interval); - return ( - <> - - {durationString} - - {item.showIntervalWarning && ( - - { - if (item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)) { - onRuleEdit(item); - } - }} - iconType="flag" - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', - { defaultMessage: 'Below configured minimum interval' } - )} - /> - - )} - - - - ); - }, - }, - { - field: 'executionStatus.lastDuration', - width: '12%', - name: ( - - - Duration{' '} - - - - ), - sortable: true, - truncateText: false, - 'data-test-subj': 'rulesTableCell-duration', - render: (value: number, item: RuleTableItem) => { - const showDurationWarning = shouldShowDurationWarning( - ruleTypesState.data.get(item.ruleTypeId), - value - ); - - return ( - <> - {} - {showDurationWarning && ( - - )} - - ); - }, - }, - getPercentileColumn(), - { - field: 'monitoring.execution.calculated_metrics.success_ratio', - width: '12%', - name: ( - - - Success ratio{' '} - - - - ), - sortable: true, - truncateText: false, - 'data-test-subj': 'rulesTableCell-successRatio', - render: (value: number) => { - return ( - - {value !== undefined ? getFormattedSuccessRatio(value) : 'N/A'} - - ); - }, - }, - { - field: 'executionStatus.status', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', - { defaultMessage: 'Last response' } - ), - sortable: true, - truncateText: false, - width: '120px', - 'data-test-subj': 'rulesTableCell-lastResponse', - render: (_executionStatus: RuleExecutionStatus, item: RuleTableItem) => { - return renderRuleExecutionStatus(item.executionStatus, item); - }, - }, - { - field: 'enabled', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.stateTitle', - { defaultMessage: 'State' } - ), - sortable: true, - truncateText: false, - width: '10%', - 'data-test-subj': 'rulesTableCell-status', - render: (_enabled: boolean | undefined, item: RuleTableItem) => { - return renderRuleStatusDropdown(item.enabled, item); - }, - }, - { - name: '', - width: '90px', - render(item: RuleTableItem) { - return ( - - - - {item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId) ? ( - - onRuleEdit(item)} - iconType={'pencil'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel', - { defaultMessage: 'Edit' } - )} - /> - - ) : null} - {item.isEditable ? ( - - setRulesToDelete([item.id])} - iconType={'trash'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.deleteAriaLabel', - { defaultMessage: 'Delete' } - )} - /> - - ) : null} - - - - loadRulesData()} - setRulesToDelete={setRulesToDelete} - onEditRule={() => onRuleEdit(item)} - onUpdateAPIKey={setRulesToUpdateAPIKey} - /> - - - ); - }, - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - name: ( - - Expand rows - - ), - render: (item: RuleTableItem) => { - const _executionStatus = item.executionStatus; - const hasErrorMessage = _executionStatus.status === 'error'; - const isLicenseError = - _executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; - - return isLicenseError || hasErrorMessage ? ( - toggleErrorMessage(_executionStatus, item)} - aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'} - iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} - /> - ) : null; - }, - }, - ]; - }; - const authorizedRuleTypes = [...ruleTypesState.data.values()]; const authorizedToCreateAnyRules = authorizedRuleTypes.some( (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all @@ -979,13 +386,29 @@ export const RulesList: React.FunctionComponent = () => { return []; }; - const getRuleStatusFilter = () => { + const renderRuleStatusFilter = () => { if (isRuleStatusFilterEnabled) { - return [ - , - ]; + return ( + + ); } - return []; + return null; + }; + + const onDisableRule = (rule: RuleTableItem) => { + return disableRule({ http, id: rule.id }); + }; + + const onEnableRule = (rule: RuleTableItem) => { + return enableRule({ http, id: rule.id }); + }; + + const onSnoozeRule = (rule: RuleTableItem, snoozeEndTime: string | -1) => { + return snoozeRule({ http, id: rule.id, snoozeEndTime }); + }; + + const onUnsnoozeRule = (rule: RuleTableItem) => { + return unsnoozeRule({ http, id: rule.id }); }; const toolsRight = [ @@ -999,8 +422,6 @@ export const RulesList: React.FunctionComponent = () => { }) )} />, - ...getRuleTagFilter(), - ...getRuleStatusFilter(), { selectedStatuses={ruleExecutionStatusesFilter} onChange={(ids: string[]) => setRuleExecutionStatusesFilter(ids)} />, - - - , + ...getRuleTagFilter(), ]; const authorizedToModifySelectedRules = selectedIds.length @@ -1074,7 +484,7 @@ export const RulesList: React.FunctionComponent = () => { })} onPerformingAction={() => setIsPerformingAction(true)} onActionPerformed={() => { - loadRulesData(); + loadData(); setIsPerformingAction(false); }} setRulesToDelete={setRulesToDelete} @@ -1119,20 +529,19 @@ export const RulesList: React.FunctionComponent = () => { )} /> + {renderRuleStatusFilter()} - + {toolsRight.map((tool, index: number) => ( - - {tool} - + {tool} ))} - + - + { /> + {rulesStatusesTotal.error > 0 && ( @@ -1235,64 +645,66 @@ export const RulesList: React.FunctionComponent = () => { )} - - ({ - 'data-test-subj': 'rule-row', - className: !ruleTypesState.data.get(item.ruleTypeId)?.enabledInLicense - ? 'actRulesList__tableRowDisabled' - : '', - })} - cellProps={(item: RuleTableItem) => ({ - 'data-test-subj': 'cell', - className: !ruleTypesState.data.get(item.ruleTypeId)?.enabledInLicense - ? 'actRulesList__tableCellDisabled' - : '', - })} - data-test-subj="rulesList" - pagination={{ - pageIndex: page.index, - pageSize: page.size, - /* Don't display rule count until we have the rule types initialized */ - totalItemCount: ruleTypesState.isInitialized === false ? 0 : rulesState.totalItemCount, - }} - selection={{ - selectable: (rule: RuleTableItem) => rule.isEditable, - onSelectionChange(updatedSelectedItemsList: RuleTableItem[]) { - setSelectedIds(updatedSelectedItemsList.map((item) => item.id)); - }, + loadData()} + onRuleClick={(rule) => { + history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }} - onChange={({ - page: changedPage, - sort: changedSort, - }: { - page?: Pagination; - sort?: EuiTableSortingType['sort']; - }) => { - if (changedPage) { - setPage(changedPage); - } - if (changedSort) { - setSort(changedSort); + onRuleEditClick={(rule) => { + if (rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId)) { + onRuleEdit(rule); } }} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - isExpandable={true} + onRuleDeleteClick={(rule) => setRulesToDelete([rule.id])} + onManageLicenseClick={(rule) => + setManageLicenseModalOpts({ + licenseType: ruleTypesState.data.get(rule.ruleTypeId)?.minimumLicenseRequired!, + ruleTypeId: rule.ruleTypeId, + }) + } + onSelectionChange={(updatedSelectedItemsList) => + setSelectedIds(updatedSelectedItemsList.map((item) => item.id)) + } + onPercentileOptionsChange={setPercentileOptions} + onDisableRule={onDisableRule} + onEnableRule={onEnableRule} + onSnoozeRule={onSnoozeRule} + onUnsnoozeRule={onUnsnoozeRule} + renderCollapsedItemActions={(rule) => ( + loadData()} + setRulesToDelete={setRulesToDelete} + onEditRule={() => onRuleEdit(rule)} + onUpdateAPIKey={setRulesToUpdateAPIKey} + /> + )} + renderRuleError={(rule) => { + const _executionStatus = rule.executionStatus; + const hasErrorMessage = _executionStatus.status === 'error'; + const isLicenseError = + _executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + + return isLicenseError || hasErrorMessage ? ( + toggleErrorMessage(_executionStatus, rule)} + aria-label={itemIdToExpandedRowMap[rule.id] ? 'Collapse' : 'Expand'} + iconType={itemIdToExpandedRowMap[rule.id] ? 'arrowUp' : 'arrowDown'} + /> + ) : null; + }} + config={config} /> {manageLicenseModalOpts && ( { onDeleted={async () => { setRulesToDelete([]); setSelectedIds([]); - await loadRulesData(); + await loadData(); }} onErrors={async () => { - // Refresh the rules from the server, some rules may have been deleted - await loadRulesData(); + // Refresh the rules from the server, some rules may have beend deleted + await loadData(); setRulesToDelete([]); }} onCancel={() => { @@ -1364,7 +776,7 @@ export const RulesList: React.FunctionComponent = () => { }} onUpdated={async () => { setRulesToUpdateAPIKey([]); - await loadRulesData(); + await loadData(); }} /> @@ -1378,7 +790,7 @@ export const RulesList: React.FunctionComponent = () => { actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} ruleTypeIndex={ruleTypesState.data} - onSave={loadRulesData} + onSave={loadData} /> )} {editFlyoutVisible && currentRuleToEdit && ( @@ -1392,7 +804,7 @@ export const RulesList: React.FunctionComponent = () => { ruleType={ ruleTypesState.data.get(currentRuleToEdit.ruleTypeId) as RuleType } - onSave={loadRulesData} + onSave={loadData} /> )} @@ -1427,30 +839,3 @@ const noPermissionPrompt = ( function filterRulesById(rules: Rule[], ids: string[]): Rule[] { return rules.filter((rule) => ids.includes(rule.id)); } - -interface ConvertRulesToTableItemsOpts { - rules: Rule[]; - ruleTypeIndex: RuleTypeIndex; - canExecuteActions: boolean; - config: TriggersActionsUiConfig; -} - -function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { - const { rules, ruleTypeIndex, canExecuteActions, config } = opts; - const minimumDuration = config.minimumScheduleInterval - ? parseDuration(config.minimumScheduleInterval.value) - : 0; - return rules.map((rule, index: number) => { - return { - ...rule, - index, - actionsCount: rule.actions.length, - ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, - isEditable: - hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && - (canExecuteActions || (!canExecuteActions && !rule.actions.length)), - enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, - showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, - }; - }); -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx new file mode 100644 index 00000000000000..9e17561ce652bd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import moment from 'moment'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { act } from 'react-dom/test-utils'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; + +const onRefresh = jest.fn(); + +describe('RulesListAutoRefresh', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the update text correctly', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a few seconds ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a minute ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated 2 minutes ago'); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); + + it('calls onRefresh when it auto refreshes', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + mountWithIntl( + + ); + + expect(onRefresh).toHaveBeenCalledTimes(0); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(2); + + await act(async () => { + jest.advanceTimersByTime(10 * 1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(12); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx new file mode 100644 index 00000000000000..eea8d8e5f1bbe2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiAutoRefreshButton } from '@elastic/eui'; + +interface RulesListAutoRefreshProps { + lastUpdate: string; + initialUpdateInterval?: number; + onRefresh: () => void; +} + +const flexGroupStyle = { + marginLeft: 'auto', +}; + +const getLastUpdateText = (lastUpdate: string) => { + if (!moment(lastUpdate).isValid()) { + return ''; + } + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListAutoRefresh.lastUpdateText', + { + defaultMessage: 'Updated {lastUpdateText}', + values: { + lastUpdateText: moment(lastUpdate).fromNow(), + }, + } + ); +}; + +const TEXT_UPDATE_INTERVAL = 60 * 1000; +const DEFAULT_REFRESH_INTERVAL = 5 * 60 * 1000; +const MIN_REFRESH_INTERVAL = 1000; + +export const RulesListAutoRefresh = (props: RulesListAutoRefreshProps) => { + const { lastUpdate, initialUpdateInterval = DEFAULT_REFRESH_INTERVAL, onRefresh } = props; + + const [isPaused, setIsPaused] = useState(false); + const [refreshInterval, setRefreshInterval] = useState( + Math.max(initialUpdateInterval, MIN_REFRESH_INTERVAL) + ); + const [lastUpdateText, setLastUpdateText] = useState(''); + + const cachedOnRefresh = useRef<() => void>(() => {}); + const textUpdateTimeout = useRef(); + const refreshTimeout = useRef(); + + useEffect(() => { + cachedOnRefresh.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + + const poll = () => { + textUpdateTimeout.current = window.setTimeout(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + poll(); + }, TEXT_UPDATE_INTERVAL); + }; + poll(); + + return () => { + if (textUpdateTimeout.current) { + clearTimeout(textUpdateTimeout.current); + } + }; + }, [lastUpdate, setLastUpdateText]); + + useEffect(() => { + if (isPaused) { + return; + } + + const poll = () => { + refreshTimeout.current = window.setTimeout(() => { + cachedOnRefresh.current(); + poll(); + }, refreshInterval); + }; + poll(); + + return () => { + if (refreshTimeout.current) { + clearTimeout(refreshTimeout.current); + } + }; + }, [isPaused, refreshInterval]); + + const onRefreshChange = useCallback( + ({ isPaused: newIsPaused, refreshInterval: newRefreshInterval }) => { + setIsPaused(newIsPaused); + setRefreshInterval(newRefreshInterval); + }, + [setIsPaused, setRefreshInterval] + ); + + return ( + + + + {lastUpdateText} + + + + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx new file mode 100644 index 00000000000000..1f03c76a7de0b9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import moment from 'moment'; +import { EuiButton, EuiButtonIcon, EuiPopover, EuiText, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isRuleSnoozed } from './rule_status_dropdown'; +import { RuleTableItem } from '../../../../types'; +import { + SnoozePanel, + futureTimeToInterval, + usePreviousSnoozeInterval, + SnoozeUnit, +} from './rule_status_dropdown'; + +export interface RulesListNotifyBadgeProps { + rule: RuleTableItem; + isOpen: boolean; + previousSnoozeInterval?: string | null; + onClick: React.MouseEventHandler; + onClose: () => void; + onRuleChanged: () => void; + snoozeRule: (snoozeEndTime: string | -1, interval: string | null) => Promise; + unsnoozeRule: () => Promise; +} + +const openSnoozePanelAriaLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.openSnoozePanel', + { defaultMessage: 'Open snooze panel' } +); + +export const RulesListNotifyBadge: React.FunctionComponent = (props) => { + const { + rule, + isOpen, + previousSnoozeInterval: propsPreviousSnoozeInterval, + onClick, + onClose, + onRuleChanged, + snoozeRule, + unsnoozeRule, + } = props; + + const { isSnoozedUntil, muteAll } = rule; + + const [previousSnoozeInterval, setPreviousSnoozeInterval] = usePreviousSnoozeInterval( + propsPreviousSnoozeInterval + ); + + const [isLoading, setIsLoading] = useState(false); + + const isSnoozedIndefinitely = muteAll; + + const isSnoozed = useMemo(() => { + return isRuleSnoozed(rule); + }, [rule]); + + const isScheduled = useMemo(() => { + // TODO: Implement scheduled check + return false; + }, []); + + const formattedSnoozeText = useMemo(() => { + if (!isSnoozedUntil) { + return ''; + } + return moment(isSnoozedUntil).format('MMM D'); + }, [isSnoozedUntil]); + + const snoozeTooltipText = useMemo(() => { + if (isSnoozedIndefinitely) { + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.snoozedIndefinitelyTooltip', + { defaultMessage: 'Notifications snoozed indefinitely' } + ); + } + if (isScheduled) { + return ''; + // TODO: Implement scheduled tooltip + } + if (isSnoozed) { + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.snoozedTooltip', + { + defaultMessage: 'Notifications snoozed for {snoozeTime}', + values: { + snoozeTime: moment(isSnoozedUntil).fromNow(true), + }, + } + ); + } + return ''; + }, [isSnoozedIndefinitely, isScheduled, isSnoozed, isSnoozedUntil]); + + const snoozedButton = useMemo(() => { + return ( + + {formattedSnoozeText} + + ); + }, [formattedSnoozeText, onClick]); + + const scheduledSnoozeButton = useMemo(() => { + // TODO: Implement scheduled snooze button + return ( + + {formattedSnoozeText} + + ); + }, [formattedSnoozeText, onClick]); + + const unsnoozedButton = useMemo(() => { + return ( + + ); + }, [isOpen, onClick]); + + const indefiniteSnoozeButton = useMemo(() => { + return ( + + ); + }, [onClick]); + + const button = useMemo(() => { + if (isScheduled) { + return scheduledSnoozeButton; + } + if (isSnoozedIndefinitely) { + return indefiniteSnoozeButton; + } + if (isSnoozed) { + return snoozedButton; + } + return unsnoozedButton; + }, [ + isSnoozed, + isScheduled, + isSnoozedIndefinitely, + scheduledSnoozeButton, + snoozedButton, + indefiniteSnoozeButton, + unsnoozedButton, + ]); + + const buttonWithToolTip = useMemo(() => { + if (isOpen) { + return button; + } + return {button}; + }, [isOpen, button, snoozeTooltipText]); + + const snoozeRuleAndStoreInterval = useCallback( + (newSnoozeEndTime: string | -1, interval: string | null) => { + if (interval) { + setPreviousSnoozeInterval(interval); + } + return snoozeRule(newSnoozeEndTime, interval); + }, + [setPreviousSnoozeInterval, snoozeRule] + ); + + const onChangeSnooze = useCallback( + async (value: number, unit?: SnoozeUnit) => { + setIsLoading(true); + try { + if (value === -1) { + await snoozeRuleAndStoreInterval(-1, null); + } else if (value !== 0) { + const newSnoozeEndTime = moment().add(value, unit).toISOString(); + await snoozeRuleAndStoreInterval(newSnoozeEndTime, `${value}${unit}`); + } else await unsnoozeRule(); + onRuleChanged(); + } finally { + onClose(); + setIsLoading(false); + } + }, + [onRuleChanged, onClose, snoozeRuleAndStoreInterval, unsnoozeRule, setIsLoading] + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx new file mode 100644 index 00000000000000..53a3b4b69f8c06 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx @@ -0,0 +1,724 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo, useState } from 'react'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiLink, + EuiButtonEmpty, + EuiHealth, + EuiText, + EuiToolTip, + EuiTableSortingType, + EuiButtonIcon, + EuiSelectableOption, + EuiIcon, + EuiScreenReaderOnly, + RIGHT_ALIGNMENT, + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableActionsColumnType, +} from '@elastic/eui'; +import { + RuleExecutionStatus, + RuleExecutionStatusErrorReasons, + formatDuration, + parseDuration, + MONITORING_HISTORY_LIMIT, +} from '@kbn/alerting-plugin/common'; +import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; +import { getHealthColor } from './rule_execution_status_filter'; +import { + Rule, + RuleTableItem, + RuleTypeIndex, + Pagination, + Percentiles, + TriggersActionsUiConfig, + RuleTypeRegistryContract, +} from '../../../../types'; +import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; +import { PercentileSelectablePopover } from './percentile_selectable_popover'; +import { RuleDurationFormat } from './rule_duration_format'; +import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; +import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; +import { hasAllPrivilege } from '../../../lib/capabilities'; +import { RuleTagBadge } from './rule_tag_badge'; +import { RuleStatusDropdown } from './rule_status_dropdown'; +import { RulesListNotifyBadge } from './rules_list_notify_badge'; + +interface RuleTypeState { + isLoading: boolean; + isInitialized: boolean; + data: RuleTypeIndex; +} + +export interface RuleState { + isLoading: boolean; + data: Rule[]; + totalItemCount: number; +} + +const percentileOrdinals = { + [Percentiles.P50]: '50th', + [Percentiles.P95]: '95th', + [Percentiles.P99]: '99th', +}; + +export const percentileFields = { + [Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50', + [Percentiles.P95]: 'monitoring.execution.calculated_metrics.p95', + [Percentiles.P99]: 'monitoring.execution.calculated_metrics.p99', +}; + +const EMPTY_OBJECT = {}; +const EMPTY_HANDLER = () => {}; +const EMPTY_RENDER = () => null; + +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +export interface RulesListTableProps { + rulesState: RuleState; + ruleTypesState: RuleTypeState; + ruleTypeRegistry: RuleTypeRegistryContract; + isLoading?: boolean; + sort: EuiTableSortingType['sort']; + page: Pagination; + percentileOptions: EuiSelectableOption[]; + canExecuteActions?: boolean; + itemIdToExpandedRowMap?: Record; + config: TriggersActionsUiConfig; + onSort?: (sort: EuiTableSortingType['sort']) => void; + onPage?: (page: Pagination) => void; + onRuleClick?: (rule: RuleTableItem) => void; + onRuleEditClick?: (rule: RuleTableItem) => void; + onRuleDeleteClick?: (rule: RuleTableItem) => void; + onManageLicenseClick?: (rule: RuleTableItem) => void; + onTagClick?: (rule: RuleTableItem) => void; + onTagClose?: (rule: RuleTableItem) => void; + onSelectionChange?: (updatedSelectedItemsList: RuleTableItem[]) => void; + onPercentileOptionsChange?: (options: EuiSelectableOption[]) => void; + onRuleChanged: () => void; + onEnableRule: (rule: RuleTableItem) => Promise; + onDisableRule: (rule: RuleTableItem) => Promise; + onSnoozeRule: (rule: RuleTableItem, snoozeEndTime: string | -1) => Promise; + onUnsnoozeRule: (rule: RuleTableItem) => Promise; + renderCollapsedItemActions?: (rule: RuleTableItem) => React.ReactNode; + renderRuleError?: (rule: RuleTableItem) => React.ReactNode; +} + +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +export function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { + const { rules, ruleTypeIndex, canExecuteActions, config } = opts; + const minimumDuration = config.minimumScheduleInterval + ? parseDuration(config.minimumScheduleInterval.value) + : 0; + return rules.map((rule, index: number) => { + return { + ...rule, + index, + actionsCount: rule.actions.length, + ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, + isEditable: + hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && + (canExecuteActions || (!canExecuteActions && !rule.actions.length)), + enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, + showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, + }; + }); +} + +export const RulesListTable = (props: RulesListTableProps) => { + const { + rulesState, + ruleTypesState, + ruleTypeRegistry, + isLoading = false, + canExecuteActions = false, + sort, + page, + percentileOptions, + itemIdToExpandedRowMap = EMPTY_OBJECT, + config = EMPTY_OBJECT as TriggersActionsUiConfig, + onSort = EMPTY_HANDLER, + onPage = EMPTY_HANDLER, + onRuleClick = EMPTY_HANDLER, + onRuleEditClick = EMPTY_HANDLER, + onRuleDeleteClick = EMPTY_HANDLER, + onManageLicenseClick = EMPTY_HANDLER, + onSelectionChange = EMPTY_HANDLER, + onPercentileOptionsChange = EMPTY_HANDLER, + onRuleChanged = EMPTY_HANDLER, + onEnableRule = EMPTY_HANDLER, + onDisableRule = EMPTY_HANDLER, + onSnoozeRule = EMPTY_HANDLER, + onUnsnoozeRule = EMPTY_HANDLER, + renderCollapsedItemActions = EMPTY_RENDER, + renderRuleError = EMPTY_RENDER, + } = props; + + const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + const [currentlyOpenNotify, setCurrentlyOpenNotify] = useState(); + + const selectedPercentile = useMemo(() => { + const selectedOption = percentileOptions.find((option) => option.checked === 'on'); + if (selectedOption) { + return Percentiles[selectedOption.key as Percentiles]; + } + }, [percentileOptions]); + + const renderPercentileColumnName = () => { + return ( + + + + {selectedPercentile}  + + + + + + ); + }; + + const renderPercentileCellValue = (value: number) => { + return ( + + + + ); + }; + + const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, rule: RuleTableItem) => { + return ( + await onDisableRule(rule)} + enableRule={async () => await onEnableRule(rule)} + snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { + await onSnoozeRule(rule, snoozeEndTime); + }} + unsnoozeRule={async () => await onUnsnoozeRule(rule)} + rule={rule} + onRuleChanged={onRuleChanged} + isEditable={rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId)} + /> + ); + }; + + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + + const renderRuleExecutionStatus = (executionStatus: RuleExecutionStatus, rule: RuleTableItem) => { + const healthColor = getHealthColor(executionStatus.status); + const tooltipMessage = + executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; + const isLicenseError = + executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + const statusMessage = isLicenseError + ? ALERT_STATUS_LICENSE_ERROR + : rulesStatusesTranslationsMapping[executionStatus.status]; + + const health = ( + + {statusMessage} + + ); + + const healthWithTooltip = tooltipMessage ? ( + + {health} + + ) : ( + health + ); + + return ( + + {healthWithTooltip} + {isLicenseError && ( + + onManageLicenseClick(rule)} + > + + + + )} + + ); + }; + + const getRulesTableColumns = (): Array< + | EuiTableFieldDataColumnType + | EuiTableComputedColumnType + | EuiTableActionsColumnType + > => { + return [ + { + field: 'name', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle', + { defaultMessage: 'Name' } + ), + sortable: true, + truncateText: true, + width: '30%', + 'data-test-subj': 'rulesTableCell-name', + render: (name: string, rule: RuleTableItem) => { + const ruleType = ruleTypesState.data.get(rule.ruleTypeId); + const checkEnabledResult = checkRuleTypeEnabled(ruleType); + const link = ( + <> + + + + + onRuleClick(rule)}> + {name} + + + + {!checkEnabledResult.isEnabled && ( + + )} + + + + + + {rule.ruleType} + + + + + ); + return <>{link}; + }, + }, + { + field: 'tags', + name: '', + sortable: false, + width: '50px', + 'data-test-subj': 'rulesTableCell-tagsPopover', + render: (ruleTags: string[], rule: RuleTableItem) => { + return ruleTags.length > 0 ? ( + setTagPopoverOpenIndex(rule.index)} + onClose={() => setTagPopoverOpenIndex(-1)} + /> + ) : null; + }, + }, + { + field: 'executionStatus.lastExecutionDate', + name: ( + + + Last run{' '} + + + + ), + sortable: true, + width: '15%', + 'data-test-subj': 'rulesTableCell-lastExecutionDate', + render: (date: Date) => { + if (date) { + return ( + <> + + + {moment(date).format('MMM D, YYYY HH:mm:ssa')} + + + + {moment(date).fromNow()} + + + + + ); + } + }, + }, + { + name: 'Notify', + width: '16%', + 'data-test-subj': 'rulesTableCell-rulesListNotify', + render: (rule: RuleTableItem) => { + return ( + setCurrentlyOpenNotify(rule.id)} + onClose={() => setCurrentlyOpenNotify('')} + onRuleChanged={onRuleChanged} + snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { + await onSnoozeRule(rule, snoozeEndTime); + }} + unsnoozeRule={async () => await onUnsnoozeRule(rule)} + /> + ); + }, + }, + { + field: 'schedule.interval', + width: '6%', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle', + { defaultMessage: 'Interval' } + ), + sortable: false, + truncateText: false, + 'data-test-subj': 'rulesTableCell-interval', + render: (interval: string, rule: RuleTableItem) => { + const durationString = formatDuration(interval); + return ( + <> + + {durationString} + + {rule.showIntervalWarning && ( + + onRuleEditClick(rule)} + iconType="flag" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', + { defaultMessage: 'Below configured minimum interval' } + )} + /> + + )} + + + + ); + }, + }, + { + field: 'executionStatus.lastDuration', + width: '12%', + name: ( + + + Duration{' '} + + + + ), + sortable: true, + truncateText: false, + 'data-test-subj': 'rulesTableCell-duration', + render: (value: number, rule: RuleTableItem) => { + const showDurationWarning = shouldShowDurationWarning( + ruleTypesState.data.get(rule.ruleTypeId), + value + ); + + return ( + <> + {} + {showDurationWarning && ( + + )} + + ); + }, + }, + { + mobileOptions: { header: false }, + field: percentileFields[selectedPercentile!], + width: '16%', + name: renderPercentileColumnName(), + 'data-test-subj': 'rulesTableCell-ruleExecutionPercentile', + sortable: true, + truncateText: false, + render: renderPercentileCellValue, + }, + { + field: 'monitoring.execution.calculated_metrics.success_ratio', + width: '12%', + name: ( + + + Success ratio{' '} + + + + ), + sortable: true, + truncateText: false, + 'data-test-subj': 'rulesTableCell-successRatio', + render: (value: number) => { + return ( + + {value !== undefined ? getFormattedSuccessRatio(value) : 'N/A'} + + ); + }, + }, + { + field: 'executionStatus.status', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', + { defaultMessage: 'Last response' } + ), + sortable: true, + truncateText: false, + width: '120px', + 'data-test-subj': 'rulesTableCell-lastResponse', + render: (_executionStatus: RuleExecutionStatus, rule: RuleTableItem) => { + return renderRuleExecutionStatus(rule.executionStatus, rule); + }, + }, + { + field: 'enabled', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.stateTitle', + { defaultMessage: 'State' } + ), + sortable: true, + truncateText: false, + width: '10%', + 'data-test-subj': 'rulesTableCell-status', + render: (_enabled: boolean | undefined, rule: RuleTableItem) => { + return renderRuleStatusDropdown(rule.enabled, rule); + }, + }, + { + name: '', + width: '90px', + render(rule: RuleTableItem) { + return ( + + + + {rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId) ? ( + + onRuleEditClick(rule)} + iconType={'pencil'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel', + { defaultMessage: 'Edit' } + )} + /> + + ) : null} + {rule.isEditable ? ( + + onRuleDeleteClick(rule)} + iconType={'trash'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.deleteAriaLabel', + { defaultMessage: 'Delete' } + )} + /> + + ) : null} + + + {renderCollapsedItemActions(rule)} + + ); + }, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + name: ( + + Expand rows + + ), + render: renderRuleError, + }, + ]; + }; + + return ( + ({ + 'data-test-subj': 'rule-row', + className: !ruleTypesState.data.get(rule.ruleTypeId)?.enabledInLicense + ? 'actRulesList__tableRowDisabled' + : '', + })} + cellProps={(rule: RuleTableItem) => ({ + 'data-test-subj': 'cell', + className: !ruleTypesState.data.get(rule.ruleTypeId)?.enabledInLicense + ? 'actRulesList__tableCellDisabled' + : '', + })} + data-test-subj="rulesList" + pagination={{ + pageIndex: page.index, + pageSize: page.size, + /* Don't display rule count until we have the rule types initialized */ + totalItemCount: ruleTypesState.isInitialized === false ? 0 : rulesState.totalItemCount, + }} + selection={{ + selectable: (rule: RuleTableItem) => rule.isEditable, + onSelectionChange, + }} + onChange={({ + page: changedPage, + sort: changedSort, + }: { + page?: Pagination; + sort?: EuiTableSortingType['sort']; + }) => { + if (changedPage) { + onPage(changedPage); + } + if (changedSort) { + onSort(changedSort); + } + }} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + isExpandable={true} + /> + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx index 6ce697f65f8980..f8cb70745911cd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx @@ -7,13 +7,7 @@ import React, { Fragment, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiTitle, -} from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiTitle } from '@elastic/eui'; interface TypeFilterProps { options: Array<{ @@ -41,53 +35,51 @@ export const TypeFilter: React.FunctionComponent = ({ }, [selectedValues]); return ( - - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="ruleTypeFilterButton" - > - - - } - > -
- {options.map((groupItem, groupIndex) => ( - - -

{groupItem.groupName}

-
- {groupItem.subOptions.map((item, index) => ( - { - const isPreviouslyChecked = selectedValues.includes(item.value); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.value)); - } else { - setSelectedValues(selectedValues.concat(item.value)); - } - }} - checked={selectedValues.includes(item.value) ? 'on' : undefined} - data-test-subj={`ruleType${item.value}FilterOption`} - > - {item.name} - - ))} -
- ))} -
-
-
+ setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="ruleTypeFilterButton" + > + + + } + > +
+ {options.map((groupItem, groupIndex) => ( + + +

{groupItem.groupName}

+
+ {groupItem.subOptions.map((item, index) => ( + { + const isPreviouslyChecked = selectedValues.includes(item.value); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.value)); + } else { + setSelectedValues(selectedValues.concat(item.value)); + } + }} + checked={selectedValues.includes(item.value) ? 'on' : undefined} + data-test-subj={`ruleType${item.value}FilterOption`} + > + {item.name} + + ))} +
+ ))} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx new file mode 100644 index 00000000000000..b315668c4fab96 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { RulesList } from '../application/sections'; + +export const getRulesListLazy = () => { + return ; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 001f63bc6cc6f5..8295fada788e37 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -76,6 +76,7 @@ export { Plugin }; export * from './plugin'; // TODO remove this import when we expose the Rules tables as a component export { loadRules } from './application/lib/rule_api/rules'; +export { loadExecutionLogAggregations } from './application/lib/rule_api/load_execution_log_aggregations'; export { loadRuleTypes } from './application/lib/rule_api'; export { loadRuleSummary } from './application/lib/rule_api/rule_summary'; export { deleteRules } from './application/lib/rule_api/delete'; @@ -89,9 +90,9 @@ export { loadRuleAggregations, loadRuleTags } from './application/lib/rule_api/a export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; export { loadRule } from './application/lib/rule_api/get_rule'; export { loadAllActions } from './application/lib/action_connector_api'; - +export { suspendedComponentWithProps } from './application/lib/suspended_component_with_props'; export { loadActionTypes } from './application/lib/action_connector_api/connector_types'; - +export { NOTIFY_WHEN_OPTIONS } from './application/sections/rule_form/rule_notify_when'; export type { TIME_UNITS } from './application/constants'; export { getTimeUnitLabel } from './common/lib/get_time_unit_label'; export type { TriggersAndActionsUiServices } from './application/app'; diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 75ca6d8fd29874..605d83a8eb32e2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -30,6 +30,7 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getRulesListLazy } from './common/get_rules_list'; import { getAlertsTableStateLazy } from './common/get_alerts_table_state'; import { AlertsTableStateProps } from './application/sections/alerts_table/alerts_table_state'; @@ -85,6 +86,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { getRuleEventLogList: (props) => { return getRuleEventLogListLazy(props); }, + getRulesList: () => { + return getRulesListLazy(); + }, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index f9df34a5e4abb7..f2237ff22f4aee 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -35,6 +35,7 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getRulesListLazy } from './common/get_rules_list'; import { ExperimentalFeaturesService } from './common/experimental_features_service'; import { ExperimentalFeatures, @@ -91,6 +92,7 @@ export interface TriggersAndActionsUIPublicPluginStart { getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement; getRuleTagBadge: (props: RuleTagBadgeProps) => ReactElement; getRuleEventLogList: (props: RuleEventLogListProps) => ReactElement; + getRulesList: () => ReactElement; } interface PluginsSetup { @@ -279,6 +281,9 @@ export class Plugin getRuleEventLogList: (props: RuleEventLogListProps) => { return getRuleEventLogListLazy(props); }, + getRulesList: () => { + return getRulesListLazy(); + }, }; } diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index d1bf39b575ab58..0c5f95189ae902 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -210,6 +210,17 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) password: 'somepassword', }, }, + 'my-deprecated-servicenow-default': { + actionTypeId: '.servicenow', + name: 'ServiceNow#xyz', + config: { + apiUrl: 'https://ven04334.service-now.com', + }, + secrets: { + username: 'elastic_integration', + password: 'somepassword', + }, + }, 'custom-system-abc-connector': { actionTypeId: 'system-abc-action-type', name: 'SystemABC', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts index 103ae5abd30714..69f618c804eb1f 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts @@ -95,6 +95,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -222,6 +230,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -313,6 +329,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts index b187b9e9f97597..b1e77b98b792d5 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts @@ -188,7 +188,7 @@ export default function createActionsTelemetryTests({ getService }: FtrProviderC const telemetry = JSON.parse(taskState!); // total number of connectors - expect(telemetry.count_total).to.equal(18); + expect(telemetry.count_total).to.equal(19); // total number of active connectors (used by a rule) expect(telemetry.count_active_total).to.equal(7); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts index d5d5109b6e7383..6d923452faac5f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts @@ -98,6 +98,18 @@ export default function getActionTests({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', name: 'ServiceNow#xyz', }); + + await supertest + .get( + `${getUrlPrefix(Spaces.space1.id)}/api/actions/connector/my-deprecated-servicenow-default` + ) + .expect(200, { + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + connector_type_id: '.servicenow', + name: 'ServiceNow#xyz', + }); }); describe('legacy', () => { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 54a0e6e10a1985..0632f48ed6e8d5 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -83,6 +83,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -162,6 +170,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -254,6 +270,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referencedByCount: 0, }, + { + actionTypeId: '.servicenow', + id: 'my-deprecated-servicenow-default', + isPreconfigured: true, + isDeprecated: true, + name: 'ServiceNow#xyz', + referencedByCount: 0, + }, { id: 'my-slack1', isPreconfigured: true, diff --git a/x-pack/test/api_integration/apis/aiops/example_stream.ts b/x-pack/test/api_integration/apis/aiops/example_stream.ts index 693a6de2c67160..c1e410655dbfc5 100644 --- a/x-pack/test/api_integration/apis/aiops/example_stream.ts +++ b/x-pack/test/api_integration/apis/aiops/example_stream.ts @@ -12,6 +12,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { parseStream } from './parse_stream'; + export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const config = getService('config'); @@ -67,34 +69,15 @@ export default ({ getService }: FtrProviderContext) => { expect(stream).not.to.be(null); if (stream !== null) { - let partial = ''; - let threw = false; const progressData: any[] = []; - try { - for await (const value of stream) { - const full = `${partial}${value}`; - const parts = full.split('\n'); - const last = parts.pop(); - - partial = last ?? ''; - - const actions = parts.map((p) => JSON.parse(p)); - - actions.forEach((action) => { - expect(typeof action.type).to.be('string'); - - if (action.type === 'update_progress') { - progressData.push(action); - } - }); + for await (const action of parseStream(stream)) { + expect(action.type).not.to.be('error'); + if (action.type === 'update_progress') { + progressData.push(action); } - } catch (e) { - threw = true; } - expect(threw).to.be(false); - expect(progressData.length).to.be(100); expect(progressData[0].payload).to.be(1); expect(progressData[progressData.length - 1].payload).to.be(100); diff --git a/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts b/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts new file mode 100644 index 00000000000000..11ef63809a52f7 --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fetch from 'node-fetch'; +import { format as formatUrl } from 'url'; + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { parseStream } from './parse_stream'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const config = getService('config'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + + const expectedFields = [ + 'category', + 'currency', + 'customer_first_name', + 'customer_full_name', + 'customer_gender', + 'customer_id', + 'customer_last_name', + 'customer_phone', + 'day_of_week', + 'day_of_week_i', + 'email', + 'geoip', + 'manufacturer', + 'order_date', + 'order_id', + 'products', + 'sku', + 'taxful_total_price', + 'taxless_total_price', + 'total_quantity', + 'total_unique_products', + 'type', + 'user', + ]; + + describe('POST /internal/aiops/explain_log_rate_spikes', () => { + const esArchiver = getService('esArchiver'); + + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); + }); + + it('should return full data without streaming', async () => { + const resp = await supertest + .post(`/internal/aiops/explain_log_rate_spikes`) + .set('kbn-xsrf', 'kibana') + .send({ + index: 'ft_ecommerce', + }) + .expect(200); + + expect(Buffer.isBuffer(resp.body)).to.be(true); + + const chunks: string[] = resp.body.toString().split('\n'); + + expect(chunks.length).to.be(24); + + const lastChunk = chunks.pop(); + expect(lastChunk).to.be(''); + + let data: any[] = []; + + expect(() => { + data = chunks.map((c) => JSON.parse(c)); + }).not.to.throwError(); + + data.forEach((d) => { + expect(typeof d.type).to.be('string'); + }); + + const fields = data.map((d) => d.payload[0]).sort(); + + expect(fields.length).to.equal(expectedFields.length); + fields.forEach((f) => { + expect(expectedFields.includes(f)); + }); + }); + + it('should return data in chunks with streaming', async () => { + const response = await fetch(`${kibanaServerUrl}/internal/aiops/explain_log_rate_spikes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'kbn-xsrf': 'stream', + }, + body: JSON.stringify({ index: 'ft_ecommerce' }), + }); + + const stream = response.body; + + expect(stream).not.to.be(null); + + if (stream !== null) { + const data: any[] = []; + + for await (const action of parseStream(stream)) { + expect(action.type).not.to.be('error'); + data.push(action); + } + + const fields = data.map((d) => d.payload[0]).sort(); + + expect(fields.length).to.equal(expectedFields.length); + fields.forEach((f) => { + expect(expectedFields.includes(f)); + }); + } + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/aiops/index.ts b/x-pack/test/api_integration/apis/aiops/index.ts index 04b4181906dbfd..8d6b6ea13399f6 100644 --- a/x-pack/test/api_integration/apis/aiops/index.ts +++ b/x-pack/test/api_integration/apis/aiops/index.ts @@ -5,12 +5,17 @@ * 2.0. */ +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; + import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('AIOps', function () { this.tags(['ml']); - loadTestFile(require.resolve('./example_stream')); + if (AIOPS_ENABLED) { + loadTestFile(require.resolve('./example_stream')); + loadTestFile(require.resolve('./explain_log_rate_spikes')); + } }); } diff --git a/x-pack/test/api_integration/apis/aiops/parse_stream.ts b/x-pack/test/api_integration/apis/aiops/parse_stream.ts new file mode 100644 index 00000000000000..f3da52e6024bb0 --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/parse_stream.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export async function* parseStream(stream: NodeJS.ReadableStream) { + let partial = ''; + + try { + for await (const value of stream) { + const full = `${partial}${value}`; + const parts = full.split('\n'); + const last = parts.pop(); + + partial = last ?? ''; + + const actions = parts.map((p) => JSON.parse(p)); + + for (const action of actions) { + yield action; + } + } + } catch (error) { + yield { type: 'error', payload: error.toString() }; + } +} diff --git a/x-pack/test/api_integration/apis/maps/get_grid_tile.js b/x-pack/test/api_integration/apis/maps/get_grid_tile.js index 46fdda09ec4765..26ba8c24ce71af 100644 --- a/x-pack/test/api_integration/apis/maps/get_grid_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_grid_tile.js @@ -9,12 +9,22 @@ import { VectorTile } from '@mapbox/vector-tile'; import Protobuf from 'pbf'; import expect from '@kbn/expect'; +function findFeature(layer, callbackFn) { + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + if (callbackFn(feature)) { + return feature; + } + } +} + export default function ({ getService }) { const supertest = getService('supertest'); describe('getGridTile', () => { const URL = `/api/maps/mvt/getGridTile/3/2/3.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=logstash-*\ &gridPrecision=8\ &requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))`; @@ -152,6 +162,33 @@ export default function ({ getService }) { ]); }); + it('should return vector tile containing label features when hasLabels is true', async () => { + const resp = await supertest + .get(URL.replace('hasLabels=false', 'hasLabels=true') + '&renderAs=hex') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); + const layer = jsonTile.layers.aggs; + expect(layer.length).to.be(2); + + const labelFeature = findFeature(layer, (feature) => { + return feature.properties._mvt_label_position === true; + }); + expect(labelFeature).not.to.be(undefined); + expect(labelFeature.type).to.be(1); + expect(labelFeature.extent).to.be(4096); + expect(labelFeature.id).to.be(undefined); + expect(labelFeature.properties).to.eql({ + _count: 1, + _key: '85264a33fffffff', + 'avg_of_bytes.value': 9252, + _mvt_label_position: true, + }); + expect(labelFeature.loadGeometry()).to.eql([[{ x: 93, y: 667 }]]); + }); + it('should return vector tile with meta layer', async () => { const resp = await supertest .get(URL + '&renderAs=point') diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index 09b8bf1d8b8629..6803b5e404ab0e 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -27,6 +27,7 @@ export default function ({ getService }) { .get( `/api/maps/mvt/getTile/2/1/1.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=logstash-*\ &requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` ) @@ -85,11 +86,57 @@ export default function ({ getService }) { ]); }); + it('should return ES vector tile containing label features when hasLabels is true', async () => { + const resp = await supertest + .get( + `/api/maps/mvt/getTile/2/1/1.pbf\ +?geometryFieldName=geo.coordinates\ +&hasLabels=true\ +&index=logstash-*\ +&requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` + ) + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + expect(resp.headers['content-encoding']).to.be('gzip'); + expect(resp.headers['content-disposition']).to.be('inline'); + expect(resp.headers['content-type']).to.be('application/x-protobuf'); + expect(resp.headers['cache-control']).to.be('public, max-age=3600'); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); + const layer = jsonTile.layers.hits; + expect(layer.length).to.be(4); // 2 docs + 2 label features + + // Verify ES document + + const feature = findFeature(layer, (feature) => { + return ( + feature.properties._id === 'AU_x3_BsGFA8no6Qjjug' && + feature.properties._mvt_label_position === true + ); + }); + expect(feature).not.to.be(undefined); + expect(feature.type).to.be(1); + expect(feature.extent).to.be(4096); + expect(feature.id).to.be(undefined); + expect(feature.properties).to.eql({ + '@timestamp': '1442709961071', + _id: 'AU_x3_BsGFA8no6Qjjug', + _index: 'logstash-2015.09.20', + bytes: 9252, + 'machine.os.raw': 'ios', + _mvt_label_position: true, + }); + expect(feature.loadGeometry()).to.eql([[{ x: 44, y: 2382 }]]); + }); + it('should return error when index does not exist', async () => { await supertest .get( `/api/maps/mvt/getTile/2/1/1.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=notRealIndex\ &requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` ) diff --git a/x-pack/test/api_integration/apis/search/search.ts b/x-pack/test/api_integration/apis/search/search.ts index e4596163048438..e7dfbb52ec701e 100644 --- a/x-pack/test/api_integration/apis/search/search.ts +++ b/x-pack/test/api_integration/apis/search/search.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import type { Context } from 'mocha'; +import { parse as parseCookie } from 'tough-cookie'; import { FtrProviderContext } from '../../ftr_provider_context'; import { verifyErrorResponse } from '../../../../../test/api_integration/apis/search/verify_error'; @@ -16,6 +17,8 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); const log = getService('log'); const retry = getService('retry'); + const security = getService('security'); + const supertestNoAuth = getService('supertestWithoutAuth'); const shardDelayAgg = (delay: string) => ({ aggs: { @@ -266,6 +269,48 @@ export default function ({ getService }: FtrProviderContext) { verifyErrorResponse(resp.body, 400, 'parsing_exception', true); }); + + it('should return 403 for lack of privledges', async () => { + const username = 'no_access'; + const password = 't0pS3cr3t'; + + await security.user.create(username, { + password, + roles: ['test_shakespeare_reader'], + }); + + const loginResponse = await supertestNoAuth + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const sessionCookie = parseCookie(loginResponse.headers['set-cookie'][0]); + + await supertestNoAuth + .post(`/internal/search/ese`) + .set('kbn-xsrf', 'foo') + .set('Cookie', sessionCookie!.cookieString()) + .send({ + params: { + index: 'log*', + body: { + query: { + match_all: {}, + }, + }, + wait_for_completion_timeout: '10s', + }, + }) + .expect(403); + + await security.testUser.restoreDefaults(); + }); }); describe('rollup', () => { diff --git a/x-pack/test/cases_api_integration/common/config.ts b/x-pack/test/cases_api_integration/common/config.ts index 89dd19ae74897d..a20dd300a4e6ee 100644 --- a/x-pack/test/cases_api_integration/common/config.ts +++ b/x-pack/test/cases_api_integration/common/config.ts @@ -144,6 +144,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) actionTypeId: '.servicenow', config: { apiUrl: 'https://example.com', + usesTableApi: false, }, secrets: { username: 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts index 7d32af43d1913d..aff63d635c976e 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts @@ -89,6 +89,9 @@ export default ({ getService }: FtrProviderContext) => { name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts index 1b7e22fb21c574..966420c90b8d25 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts @@ -171,6 +171,9 @@ export default ({ getService }: FtrProviderContext) => { name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts index 865185387c57c0..5382ba5fd18f4c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts @@ -353,6 +353,9 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', index: ['.siem-signals-*'], query: '*:*', + related_integrations: [], + required_fields: [], + setup: '', }, 'kibana.alert.rule.actions': [], 'kibana.alert.rule.created_by': 'elastic', @@ -518,6 +521,9 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', index: ['.alerts-security.alerts-default'], query: '*:*', + related_integrations: [], + required_fields: [], + setup: '', }, 'kibana.alert.rule.actions': [], 'kibana.alert.rule.created_by': 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts b/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts index 98fdfa99cbd3c0..81a169636605b5 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts @@ -97,4 +97,7 @@ export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial => version: 1, query: 'user.name: root or user.name: admin', exceptions_list: [], + related_integrations: [], + required_fields: [], + setup: '', }); diff --git a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts index 30dc7eecb92567..ca8b04e66f3fc2 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts @@ -26,11 +26,14 @@ export const getSimpleRuleOutput = (ruleId = 'rule-1', enabled = false): Partial language: 'kuery', output_index: '.siem-signals-default', max_signals: 100, + related_integrations: [], + required_fields: [], risk_score: 1, risk_score_mapping: [], name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index ddb93177890694..0d06a1ca9e0f70 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -738,7 +738,6 @@ const expectAssetsInstalled = ({ }, name: 'all_assets', version: '0.1.0', - removable: true, install_version: '0.1.0', install_status: 'installed', install_started_at: res.attributes.install_started_at, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 6cbedf68da5672..e367e76049b725 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -498,7 +498,6 @@ export default function (providerContext: FtrProviderContext) { ], name: 'all_assets', version: '0.2.0', - removable: true, install_version: '0.2.0', install_status: 'installed', install_started_at: res.attributes.install_started_at, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml index ec3586689becf4..c4fb3f967913d7 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml @@ -10,8 +10,6 @@ release: beta # The default type is integration and will be set if empty. type: integration license: basic -# This package can be removed -removable: true requirement: elasticsearch: diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml index f1ed5a8a5a78ba..472888818e7179 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml @@ -10,8 +10,6 @@ release: beta # The default type is integration and will be set if empty. type: integration license: basic -# This package can be removed -removable: true requirement: elasticsearch: diff --git a/x-pack/test/functional/apps/maps/group1/layer_visibility.js b/x-pack/test/functional/apps/maps/group1/layer_visibility.js index cf6051cde8be7a..a9bbefbff86caf 100644 --- a/x-pack/test/functional/apps/maps/group1/layer_visibility.js +++ b/x-pack/test/functional/apps/maps/group1/layer_visibility.js @@ -10,6 +10,7 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const inspector = getService('inspector'); + const testSubjects = getService('testSubjects'); const security = getService('security'); describe('layer visibility', () => { @@ -31,6 +32,7 @@ export default function ({ getPageObjects, getService }) { it('should fetch layer data when layer is made visible', async () => { await PageObjects.maps.toggleLayerVisibility('logstash'); + await testSubjects.click('mapLayerTOC'); // Tooltip blocks clicks otherwise const hits = await PageObjects.maps.getHits(); expect(hits).to.equal('5'); }); diff --git a/x-pack/test/functional/apps/maps/group1/sample_data.js b/x-pack/test/functional/apps/maps/group1/sample_data.js index cf8bd4c85cf263..62df1d3859a451 100644 --- a/x-pack/test/functional/apps/maps/group1/sample_data.js +++ b/x-pack/test/functional/apps/maps/group1/sample_data.js @@ -165,8 +165,8 @@ export default function ({ getPageObjects, getService, updateBaselines }) { describe('web logs', () => { before(async () => { await PageObjects.maps.loadSavedMap('[Logs] Total Requests and Bytes'); - await PageObjects.maps.toggleLayerVisibility('Road map - desaturated'); await PageObjects.maps.toggleLayerVisibility('Total Requests by Destination'); + await PageObjects.maps.toggleLayerVisibility('Road map - desaturated'); await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); await PageObjects.maps.closeLegend(); diff --git a/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js b/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js index 40dfa5ac8e5719..66eb54278e580e 100644 --- a/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js +++ b/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js @@ -45,6 +45,7 @@ export default function ({ getPageObjects, getService }) { expect(searchParams).to.eql({ geometryFieldName: 'geo.coordinates', + hasLabels: 'false', index: 'logstash-*', gridPrecision: 8, renderAs: 'grid', diff --git a/x-pack/test/functional/apps/maps/group4/mvt_scaling.js b/x-pack/test/functional/apps/maps/group4/mvt_scaling.js index 0f74752d01136f..5f740e9137cdbc 100644 --- a/x-pack/test/functional/apps/maps/group4/mvt_scaling.js +++ b/x-pack/test/functional/apps/maps/group4/mvt_scaling.js @@ -50,6 +50,7 @@ export default function ({ getPageObjects, getService }) { expect(searchParams).to.eql({ geometryFieldName: 'geometry', + hasLabels: 'false', index: 'geo_shapes*', requestBody: '(_source:!f,docvalue_fields:!(prop1),query:(bool:(filter:!(),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10001,stored_fields:!(geometry,prop1))', diff --git a/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts index c43cf74e3048c8..69ecc7f446b58d 100644 --- a/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts @@ -169,7 +169,6 @@ const testADJobs: Array<{ job: Job; datafeed: Datafeed }> = [ ]; const testDFAJobs: DataFrameAnalyticsConfig[] = [ - // @ts-expect-error not full interface { id: `bm_1_1`, description: @@ -198,7 +197,6 @@ const testDFAJobs: DataFrameAnalyticsConfig[] = [ model_memory_limit: '60mb', allow_lazy_start: false, }, - // @ts-expect-error not full interface { id: `ihp_1_2`, description: 'This is the job description', @@ -221,7 +219,6 @@ const testDFAJobs: DataFrameAnalyticsConfig[] = [ }, model_memory_limit: '5mb', }, - // @ts-expect-error not full interface { id: `egs_1_3`, description: 'This is the job description', diff --git a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts index bae045fc93838f..2cb77ac262ca67 100644 --- a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts +++ b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts @@ -30,6 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const supertest = getService('supertest'); const queryBar = getService('queryBar'); const security = getService('security'); + const filterBar = getService('filterBar'); const SOURCE_DATA_INDEX = 'search-source-alert'; const OUTPUT_DATA_INDEX = 'search-source-alert-output'; @@ -47,17 +48,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { mappings: { properties: { '@timestamp': { type: 'date' }, - message: { type: 'text' }, + message: { type: 'keyword' }, }, }, }, }); const generateNewDocs = async (docsNumber: number) => { - const mockMessages = new Array(docsNumber).map((current) => `msg-${current}`); + const mockMessages = Array.from({ length: docsNumber }, (_, i) => `msg-${i}`); const dateNow = new Date().toISOString(); - for (const message of mockMessages) { - await es.transport.request({ + for await (const message of mockMessages) { + es.transport.request({ path: `/${SOURCE_DATA_INDEX}/_doc`, method: 'POST', body: { @@ -212,7 +213,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateToDiscover(link); }; - const openAlertRule = async () => { + const openAlertRuleInManagement = async () => { await PageObjects.common.navigateToApp('management'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -229,7 +230,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await security.testUser.setRoles(['discover_alert']); - log.debug('create source index'); + log.debug('create source indices'); await createSourceIndex(); log.debug('generate documents'); @@ -250,8 +251,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - // delete only remaining output index - await es.transport.request({ + es.transport.request({ path: `/${OUTPUT_DATA_INDEX}`, method: 'DELETE', }); @@ -272,7 +272,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await defineSearchSourceAlert(RULE_NAME); await PageObjects.header.waitUntilLoadingHasFinished(); - await openAlertRule(); + await openAlertRuleInManagement(); await testSubjects.click('ruleDetails-viewInApp'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -298,10 +298,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should display warning about updated alert rule', async () => { - await openAlertRule(); + await openAlertRuleInManagement(); // change rule configuration await testSubjects.click('openEditRuleFlyoutButton'); + await queryBar.setQuery('message:msg-1'); + await filterBar.addFilter('message.keyword', 'is', 'msg-1'); + await testSubjects.click('thresholdPopover'); await testSubjects.setValue('alertThresholdInput', '1'); await testSubjects.click('saveEditedRuleButton'); @@ -311,7 +314,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateToResults(); const { message, title } = await getLastToast(); - expect(await dataGrid.getDocCount()).to.be(5); + const queryString = await queryBar.getQueryString(); + const hasFilter = await filterBar.hasFilter('message.keyword', 'msg-1'); + + expect(queryString).to.be.equal('message:msg-1'); + expect(hasFilter).to.be.equal(true); + + expect(await dataGrid.getDocCount()).to.be(1); expect(title).to.be.equal('Alert rule has changed'); expect(message).to.be.equal( 'The displayed documents might not match the documents that triggered the alert because the rule configuration changed.' diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts index 56026093c88dd1..27989942d3e955 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_table.ts @@ -87,48 +87,41 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ); }); - // This keeps failing in CI because the next button is not clickable - // Revisit this once we change the UI around based on feedback - /* - fail: Actions and Triggers app Alerts table should open a flyout and paginate through the flyout - │ Error: retry.try timeout: ElementClickInterceptedError: element click intercepted: Element ... is not clickable at point (1564, 795). Other element would receive the click:
...
- */ - // it('should open a flyout and paginate through the flyout', async () => { - // await PageObjects.common.navigateToUrlWithBrowserHistory('triggersActions', '/alerts'); - // await waitTableIsLoaded(); - // await testSubjects.click('expandColumnCellOpenFlyoutButton-0'); - // await waitFlyoutOpen(); - // await waitFlyoutIsLoaded(); - - // expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( - // 'APM Failed Transaction Rate (one)' - // ); - // expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( - // 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' - // ); - - // await testSubjects.click('pagination-button-next'); - - // expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( - // 'APM Failed Transaction Rate (one)' - // ); - // expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( - // 'Failed transactions rate is greater than 5.0% (current value is 35%) for opbeans-python' - // ); - - // await testSubjects.click('pagination-button-previous'); - // await testSubjects.click('pagination-button-previous'); - - // await waitTableIsLoaded(); - - // const rows = await getRows(); - // expect(rows[0].status).to.be('close'); - // expect(rows[0].lastUpdated).to.be('2021-10-19T14:55:14.503Z'); - // expect(rows[0].duration).to.be('252002000'); - // expect(rows[0].reason).to.be( - // 'CPU usage is greater than a threshold of 40 (current value is 56.7%) for gke-edge-oblt-default-pool-350b44de-c3dd' - // ); - // }); + it('should open a flyout and paginate through the flyout', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory('triggersActions', '/alerts'); + await waitTableIsLoaded(); + await testSubjects.click('expandColumnCellOpenFlyoutButton-0'); + await waitFlyoutOpen(); + await waitFlyoutIsLoaded(); + + expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( + 'APM Failed Transaction Rate (one)' + ); + expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' + ); + + await testSubjects.click('alertsFlyoutPagination > pagination-button-next'); + + expect(await testSubjects.getVisibleText('alertsFlyoutName')).to.be( + 'APM Failed Transaction Rate (one)' + ); + expect(await testSubjects.getVisibleText('alertsFlyoutReason')).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 35%) for opbeans-python' + ); + + await testSubjects.click('alertsFlyoutPagination > pagination-button-previous'); + + await waitTableIsLoaded(); + + const rows = await getRows(); + expect(rows[0].status).to.be('active'); + expect(rows[0].lastUpdated).to.be('2021-10-19T15:20:38.749Z'); + expect(rows[0].duration).to.be('1197194000'); + expect(rows[0].reason).to.be( + 'Failed transactions rate is greater than 5.0% (current value is 31%) for elastic-co-frontend' + ); + }); async function waitTableIsLoaded() { return await retry.try(async () => { @@ -137,19 +130,19 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } - // async function waitFlyoutOpen() { - // return await retry.try(async () => { - // const exists = await testSubjects.exists('alertsFlyout'); - // if (!exists) throw new Error('Still loading...'); - // }); - // } - - // async function waitFlyoutIsLoaded() { - // return await retry.try(async () => { - // const exists = await testSubjects.exists('alertsFlyoutLoading'); - // if (exists) throw new Error('Still loading...'); - // }); - // } + async function waitFlyoutOpen() { + return await retry.try(async () => { + const exists = await testSubjects.exists('alertsFlyout'); + if (!exists) throw new Error('Still loading...'); + }); + } + + async function waitFlyoutIsLoaded() { + return await retry.try(async () => { + const exists = await testSubjects.exists('alertsFlyoutLoading'); + if (exists) throw new Error('Still loading...'); + }); + } async function getRows() { const euiDataGridRows = await find.allByCssSelector('.euiDataGridRow'); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts index c5ed118c105bb7..832cf6c7a90780 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -20,5 +20,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./rule_status_filter')); loadTestFile(require.resolve('./rule_tag_badge')); loadTestFile(require.resolve('./rule_event_log_list')); + loadTestFile(require.resolve('./rules_list')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts index 77d57e2819db59..15ea8fc302622d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts @@ -10,7 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); - const find = getService('find'); const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const esArchiver = getService('esArchiver'); @@ -31,24 +30,5 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const exists = await testSubjects.exists('ruleTagFilter'); expect(exists).to.be(true); }); - - it('should allow tag filters to be selected', async () => { - let badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('0'); - - await testSubjects.click('ruleTagFilter'); - await testSubjects.click('ruleTagFilterOption-tag1'); - - badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('1'); - - await testSubjects.click('ruleTagFilterOption-tag2'); - - badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('2'); - - await testSubjects.click('ruleTagFilterOption-tag1'); - expect(await badge.getVisibleText()).to.be('1'); - }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts new file mode 100644 index 00000000000000..30baba0caaa08d --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const esArchiver = getService('esArchiver'); + + describe('Rules list', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'triggersActions', + '/__components_sandbox' + ); + }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + }); + + it('shoud load from shareable lazy loader', async () => { + await testSubjects.find('rulesList'); + const exists = await testSubjects.exists('rulesList'); + expect(exists).to.be(true); + }); + }); +}; diff --git a/x-pack/test/observability_functional/apps/observability/alerts/index.ts b/x-pack/test/observability_functional/apps/observability/alerts/index.ts index e1fd795d55ffb3..5afdb0b00c774d 100644 --- a/x-pack/test/observability_functional/apps/observability/alerts/index.ts +++ b/x-pack/test/observability_functional/apps/observability/alerts/index.ts @@ -223,7 +223,9 @@ export default ({ getService }: FtrProviderContext) => { const actionsButton = await observability.alerts.common.getActionsButtonByIndex(0); await actionsButton.click(); await observability.alerts.common.viewRuleDetailsButtonClick(); - expect(await find.existsByCssSelector('[title="Rules and Connectors"]')).to.eql(true); + expect( + await (await find.byCssSelector('[data-test-subj="breadcrumb first"]')).getVisibleText() + ).to.eql('Observability'); }); }); diff --git a/x-pack/test/security_solution_endpoint/services/endpoint.ts b/x-pack/test/security_solution_endpoint/services/endpoint.ts index 27dcd67c6d684c..d526c59ee68642 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint.ts @@ -11,6 +11,7 @@ import { metadataCurrentIndexPattern, metadataTransformPrefix, METADATA_UNITED_INDEX, + METADATA_UNITED_TRANSFORM, } from '@kbn/security-solution-plugin/common/endpoint/constants'; import { deleteIndexedHostsAndAlerts, @@ -77,6 +78,27 @@ export class EndpointTestResources extends FtrService { await this.transform.api.updateTransform(transform.id, { frequency }).catch(catchAndWrapError); } + private async stopTransform(transformId: string) { + const stopRequest = { + transform_id: `${transformId}*`, + force: true, + wait_for_completion: true, + allow_no_match: true, + }; + return this.esClient.transform.stopTransform(stopRequest); + } + + private async startTransform(transformId: string) { + const transformsResponse = await this.esClient.transform.getTransform({ + transform_id: `${transformId}*`, + }); + return Promise.all( + transformsResponse.transforms.map((transform) => { + return this.esClient.transform.startTransform({ transform_id: transform.id }); + }) + ); + } + /** * Loads endpoint host/alert/event data into elasticsearch * @param [options] @@ -86,6 +108,8 @@ export class EndpointTestResources extends FtrService { * @param [options.enableFleetIntegration=true] When set to `true`, Fleet data will also be loaded (ex. Integration Policies, Agent Policies, "fake" Agents) * @param [options.generatorSeed='seed`] The seed to be used by the data generator. Important in order to ensure the same data is generated on very run. * @param [options.waitUntilTransformed=true] If set to `true`, the data loading process will wait until the endpoint hosts metadata is processed by the transform + * @param [options.waitTimeout=60000] If waitUntilTransformed=true, number of ms to wait until timeout + * @param [options.customIndexFn] If provided, will use this function to generate and index data instead */ async loadEndpointData( options: Partial<{ @@ -95,6 +119,8 @@ export class EndpointTestResources extends FtrService { enableFleetIntegration: boolean; generatorSeed: string; waitUntilTransformed: boolean; + waitTimeout: number; + customIndexFn: () => Promise; }> = {} ): Promise { const { @@ -104,25 +130,39 @@ export class EndpointTestResources extends FtrService { enableFleetIntegration = true, generatorSeed = 'seed', waitUntilTransformed = true, + waitTimeout = 60000, + customIndexFn, } = options; + if (waitUntilTransformed) { + // need this before indexing docs so that the united transform doesn't + // create a checkpoint with a timestamp after the doc timestamps + await this.stopTransform(METADATA_UNITED_TRANSFORM); + } + // load data into the system - const indexedData = await indexHostsAndAlerts( - this.esClient as Client, - this.kbnClient, - generatorSeed, - numHosts, - numHostDocs, - 'metrics-endpoint.metadata-default', - 'metrics-endpoint.policy-default', - 'logs-endpoint.events.process-default', - 'logs-endpoint.alerts-default', - alertsPerHost, - enableFleetIntegration - ); + const indexedData = customIndexFn + ? await customIndexFn() + : await indexHostsAndAlerts( + this.esClient as Client, + this.kbnClient, + generatorSeed, + numHosts, + numHostDocs, + 'metrics-endpoint.metadata-default', + 'metrics-endpoint.policy-default', + 'logs-endpoint.events.process-default', + 'logs-endpoint.alerts-default', + alertsPerHost, + enableFleetIntegration + ); if (waitUntilTransformed) { - await this.waitForEndpoints(indexedData.hosts.map((host) => host.agent.id)); + const metadataIds = Array.from(new Set(indexedData.hosts.map((host) => host.agent.id))); + await this.waitForEndpoints(metadataIds, waitTimeout); + await this.startTransform(METADATA_UNITED_TRANSFORM); + const agentIds = Array.from(new Set(indexedData.agents.map((agent) => agent.agent!.id))); + await this.waitForUnitedEndpoints(agentIds, waitTimeout); } return indexedData; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts index f560103c6c862a..1a009aaef07ec6 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_authz.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; import { wrapErrorAndRejectPromise } from '@kbn/security-solution-plugin/common/endpoint/data_loaders/utils'; import { FtrProviderContext } from '../ftr_provider_context'; import { @@ -15,23 +14,15 @@ import { } from '../../common/services/security_solution'; export default function ({ getService }: FtrProviderContext) { - const endpointTestResources = getService('endpointTestResources'); const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('When attempting to call an endpoint api with no authz', () => { - let loadedData: IndexedHostsAndAlertsResponse; - before(async () => { // create role/user await createUserAndRole(getService, ROLES.t1_analyst); - loadedData = await endpointTestResources.loadEndpointData(); }); after(async () => { - if (loadedData) { - await endpointTestResources.unloadEndpointData(loadedData); - } - // delete role/user await deleteUserAndRole(getService, ROLES.t1_analyst); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 9b023e6992385b..047b21827c5c3b 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -19,6 +19,8 @@ import { import { AGENTS_INDEX } from '@kbn/fleet-plugin/common'; import { indexFleetEndpointPolicy } from '@kbn/security-solution-plugin/common/endpoint/data_loaders/index_fleet_endpoint_policy'; import { TRANSFORM_STATES } from '@kbn/security-solution-plugin/common/constants'; +import type { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; + import { generateAgentDocs, generateMetadataDocs } from './metadata.fixtures'; import { deleteAllDocsFromMetadataCurrentIndex, @@ -47,38 +49,37 @@ export default function ({ getService }: FtrProviderContext) { const numberOfHostsInFixture = 2; before(async () => { - await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); await deleteAllDocsFromFleetAgents(getService); await deleteAllDocsFromMetadataDatastream(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); - // generate an endpoint policy and attach id to agents since - // metadata list api filters down to endpoint policies only - const policy = await indexFleetEndpointPolicy( - getService('kibanaServer'), - `Default ${uuid.v4()}`, - '1.1.1' - ); - const policyId = policy.integrationPolicies[0].policy_id; - const currentTime = new Date().getTime(); + const customIndexFn = async (): Promise => { + // generate an endpoint policy and attach id to agents since + // metadata list api filters down to endpoint policies only + const policy = await indexFleetEndpointPolicy( + getService('kibanaServer'), + `Default ${uuid.v4()}`, + '1.1.1' + ); + const policyId = policy.integrationPolicies[0].policy_id; + const currentTime = new Date().getTime(); - const agentDocs = generateAgentDocs(currentTime, policyId); + const agentDocs = generateAgentDocs(currentTime, policyId); + const metadataDocs = generateMetadataDocs(currentTime); - await Promise.all([ - bulkIndex(getService, AGENTS_INDEX, agentDocs), - bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(currentTime)), - ]); + await Promise.all([ + bulkIndex(getService, AGENTS_INDEX, agentDocs), + bulkIndex(getService, METADATA_DATASTREAM, metadataDocs), + ]); - await endpointTestResources.waitForEndpoints( - agentDocs.map((doc) => doc.agent.id), - 60000 - ); - await startTransform(getService, METADATA_UNITED_TRANSFORM); - await endpointTestResources.waitForUnitedEndpoints( - agentDocs.map((doc) => doc.agent.id), - 60000 - ); + return { + agents: agentDocs, + hosts: metadataDocs, + } as unknown as IndexedHostsAndAlertsResponse; + }; + + await endpointTestResources.loadEndpointData({ customIndexFn }); }); after(async () => { diff --git a/yarn.lock b/yarn.lock index ef1d5d849ca75e..35c60d9444f327 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,10 +1503,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@55.1.2": - version "55.1.2" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-55.1.2.tgz#dd0b42f5b26c5800d6a9cb2d4c2fe1afce9d3f07" - integrity sha512-wwZz5KxMIMFlqEsoCRiQBJDc4CrluS1d0sCOmQ5lhIzKhYc91MdxnqCk2i6YkhL4sSDf2Y9KAEuMXa+uweOWUA== +"@elastic/eui@55.1.3": + version "55.1.3" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-55.1.3.tgz#976142b88156caab2ce896102a1e35fecdaa2647" + integrity sha512-Hf6eN9YKOKAQMMS9OV5pHLUkzpKKAxGYNVSfc/KK7hN9BlhlHH4OaZIQP3Psgf5GKoqhZrldT/N65hujk3rlLA== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" @@ -3216,6 +3216,11 @@ version "0.0.0" uid "" + +"@kbn/shared-ux-prompt-no-data-views@link:bazel-bin/packages/shared-ux/prompt/no_data_views": + version "0.0.0" + uid "" + "@kbn/shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services": version "0.0.0" uid "" @@ -6421,6 +6426,11 @@ version "0.0.0" uid "" + +"@types/kbn__shared-ux-prompt-no-data-views@link:bazel-bin/packages/shared-ux/prompt/no_data_views/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types": version "0.0.0" uid "" @@ -7102,10 +7112,12 @@ resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f" integrity sha512-SMA+fUwULwK7sd/ZJicUztiPs8F1yCPwF3O23Z9uQ32ME5Ha0NmDK9+QTsYE4O2tHXChzXomSWWeIhCnoN1LqA== -"@types/selenium-webdriver@^4.0.19": - version "4.0.19" - resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.19.tgz#25699713552a63ee70215effdfd2e5d6dda19f8e" - integrity sha512-Irrh+iKc6Cxj6DwTupi4zgWhSBm1nK+JElOklIUiBVE6rcLYDtT1mwm9oFkHie485BQXNmZRoayjwxhowdInnA== +"@types/selenium-webdriver@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.1.0.tgz#b23ba7e7f4f59069529c57f0cbb7f5fba74affe7" + integrity sha512-ehqwZemosqiWVe+W0f5GqcLH7NgtjMBmcknmeaPG6YZHc7EZ69XbD7VVNZcT/L8lyMIL/KG99MsGcvDuFWo3Yw== + dependencies: + "@types/ws" "*" "@types/semver@^7": version "7.3.4" @@ -7377,6 +7389,13 @@ resolved "https://registry.yarnpkg.com/@types/write-pkg/-/write-pkg-3.1.0.tgz#f58767f4fb9a6a3ad8e95d3e9cd1f2d026ceab26" integrity sha512-JRGsPEPCrYqTXU0Cr+Yu7esPBE2yvH7ucOHr+JuBy0F59kglPvO5gkmtyEvf3P6dASSkScvy/XQ6SC1QEBFDuA== +"@types/ws@*": + version "8.5.3" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" + integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== + dependencies: + "@types/node" "*" + "@types/xml-crypto@^1.4.2": version "1.4.2" resolved "https://registry.yarnpkg.com/@types/xml-crypto/-/xml-crypto-1.4.2.tgz#5ea7ef970f525ae8fe1e2ce0b3d40da1e3b279ae" @@ -9428,20 +9447,6 @@ brfs@^2.0.0, brfs@^2.0.2: static-module "^3.0.2" through2 "^2.0.0" -broadcast-channel@4.10.0: - version "4.10.0" - resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-4.10.0.tgz#d19fb902df227df40b1b580351713d30c302d198" - integrity sha512-hOUh312XyHk6JTVyX9cyXaH1UYs+2gHVtnW16oQAu9FL7ALcXGXc/YoJWqlkV8vUn14URQPMmRi4A9q4UrwVEQ== - dependencies: - "@babel/runtime" "^7.16.0" - detect-node "^2.1.0" - microseconds "0.2.0" - nano-time "1.0.0" - oblivious-set "1.0.0" - p-queue "6.6.2" - rimraf "3.0.2" - unload "2.3.1" - broadcast-channel@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.4.1.tgz#65b63068d0a5216026a19905c9b2d5e9adf0928a" @@ -10259,10 +10264,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^100.0.0: - version "100.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-100.0.0.tgz#1b4bf5c89cea12c79f53bc94d8f5bb5aa79ed7be" - integrity sha512-oLfB0IgFEGY9qYpFQO/BNSXbPw7bgfJUN5VX8Okps9W2qNT4IqKh5hDwKWtpUIQNI6K3ToWe2/J5NdpurTY02g== +chromedriver@^101.0.0: + version "101.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-101.0.0.tgz#ad19003008dd5df1770a1ad96059a9c5fe78e365" + integrity sha512-LkkWxy6KM/0YdJS8qBeg5vfkTZTRamhBfOttb4oic4echDgWvCU1E8QcBbUBOHqZpSrYMyi7WMKmKMhXFUaZ+w== dependencies: "@testim/chrome-version" "^1.1.2" axios "^0.24.0" @@ -12520,7 +12525,7 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -detect-node@2.1.0, detect-node@^2.0.4, detect-node@^2.1.0: +detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== @@ -13921,7 +13926,7 @@ eventemitter2@^6.4.3: resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== -eventemitter3@^4.0.0, eventemitter3@^4.0.4: +eventemitter3@^4.0.0: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== @@ -21304,11 +21309,6 @@ objectorarray@^1.0.4: resolved "https://registry.yarnpkg.com/objectorarray/-/objectorarray-1.0.4.tgz#d69b2f0ff7dc2701903d308bb85882f4ddb49483" integrity sha512-91k8bjcldstRz1bG6zJo8lWD7c6QXcB4nTDUqiEvIL1xAsLoZlOOZZG+nd6YPz+V7zY1580J4Xxh1vZtyv4i/w== -oblivious-set@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/oblivious-set/-/oblivious-set-1.0.0.tgz#c8316f2c2fb6ff7b11b6158db3234c49f733c566" - integrity sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw== - oboe@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/oboe/-/oboe-2.1.4.tgz#20c88cdb0c15371bb04119257d4fdd34b0aa49f6" @@ -21654,14 +21654,6 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-queue@6.6.2: - version "6.6.2" - resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" - integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== - dependencies: - eventemitter3 "^4.0.4" - p-timeout "^3.2.0" - p-retry@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-3.0.1.tgz#316b4c8893e2c8dc1cfa891f406c4b422bebf328" @@ -21684,13 +21676,6 @@ p-timeout@^2.0.1: dependencies: p-finally "^1.0.0" -p-timeout@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" - integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== - dependencies: - p-finally "^1.0.0" - p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -25539,10 +25524,10 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selenium-webdriver@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.1.1.tgz#da083177d811f36614950e809e2982570f67d02e" - integrity sha512-Fr9e9LC6zvD6/j7NO8M1M/NVxFX67abHcxDJoP5w2KN/Xb1SyYLjMVPGgD14U2TOiKe4XKHf42OmFw9g2JgCBQ== +selenium-webdriver@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.1.2.tgz#d463b4335632d2ea41a9e988e435a55dc41f5314" + integrity sha512-e4Ap8vQvhipgBB8Ry9zBiKGkU6kHKyNnWiavGGLKkrdW81Zv7NVMtFOL/j3yX0G8QScM7XIXijKssNd4EUxSOw== dependencies: jszip "^3.6.0" tmp "^0.2.1" @@ -28557,14 +28542,6 @@ unload@2.2.0: "@babel/runtime" "^7.6.2" detect-node "^2.0.4" -unload@2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/unload/-/unload-2.3.1.tgz#9d16862d372a5ce5cb630ad1309c2fd6e35dacfe" - integrity sha512-MUZEiDqvAN9AIDRbbBnVYVvfcR6DrjCqeU2YQMmliFZl9uaBUjTkhuDQkBiyAy8ad5bx1TXVbqZ3gg7namsWjA== - dependencies: - "@babel/runtime" "^7.6.2" - detect-node "2.1.0" - unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"