diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f2d67498130131..5fcb619af65704 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -252,6 +252,7 @@ /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-core /src/plugins/security_oss/ @elastic/kibana-security /src/plugins/spaces_oss/ @elastic/kibana-security +/src/plugins/user_setup/ @elastic/kibana-security /test/security_functional/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security diff --git a/dev_docs/assets/pr_details.png b/dev_docs/assets/pr_details.png new file mode 100644 index 00000000000000..2d7428ef141e0c Binary files /dev/null and b/dev_docs/assets/pr_details.png differ diff --git a/dev_docs/assets/pr_header.png b/dev_docs/assets/pr_header.png new file mode 100644 index 00000000000000..3790c68f15fc73 Binary files /dev/null and b/dev_docs/assets/pr_header.png differ diff --git a/dev_docs/assets/pr_open.png b/dev_docs/assets/pr_open.png new file mode 100644 index 00000000000000..97028386847467 Binary files /dev/null and b/dev_docs/assets/pr_open.png differ diff --git a/dev_docs/tutorials/submit_a_pull_request.mdx b/dev_docs/tutorials/submit_a_pull_request.mdx new file mode 100644 index 00000000000000..2be5973bb38542 --- /dev/null +++ b/dev_docs/tutorials/submit_a_pull_request.mdx @@ -0,0 +1,85 @@ +--- +id: kibDevTutorialSubmitPullRequest +slug: /kibana-dev-docs/tutorial/submit-pull-request +title: Submitting a Kibana pull request +summary: Learn how to submit a Kibana pull request +date: 2021-06-24 +tags: ['kibana', 'onboarding', 'dev', 'tutorials', 'github', 'pr', 'pull request', 'ci'] +--- + +## Create and clone a fork of Kibana + +Kibana has hundreds of developers, some of whom are outside of Elastic, so we use a fork-based approach for creating branches and pull requests. + +To create and clone a fork: + +1. Login to [GitHub](https://github.com) +2. Navigate to the [Kibana repository](https://github.com/elastic/kibana) +3. Follow the [GitHub instructions](https://docs.github.com/en/get-started/quickstart/fork-a-repo) for forking and cloning repos + +## Create a branch + +After cloning your fork and navigating to the directory containing your fork: + +```bash +# Make sure you currently have the branch checked out off of which you'd like to work +git checkout master + +# Create a new branch +git checkout -b fix-typos-in-readme + +# Edit some code +vi README.md + +# Add/commit the change +git add README.md +git commit -m "Fixed all of the typos in the README" + +# Push the branch to your fork +git push -u origin fix-typos-in-readme +``` + +If this is a new branch, you will see a link in your terminal that points you directly to a page to create a pull request for that branch. + +## Create a pull request + +1. Navigate to your fork in Github +2. If you see your branch at the top of the screen with a `Compare & pull request` button, click that. Otherwise: + 1. Navigate to your branch + 2. Click Contribute, followed by `Open pull request` +3. Fill out the details that are relevant for your change in the pull request template + 1. If your pull request relates to an open issue, you can also reference that issue here, e.g. `Closes #12345` +4. [Elastic employees only] Add any teams/people that need to review your code under Reviewers. There's a good chance one or more teams will automatically be added based on which part of the codebase in which your changes were made. +5. [Elastic employees only] Add any relevant labels + 1. Versions: Add a label for each version of Kibana in which your change will ship. For example, `v8.0.0`, `v7.14.0` + 2. Features: Add labels for any relevant feature areas, e.g. `Feature:Development` + 3. Team: Most PRs should have at least one `Team:` label. Add labels for teams that should follow or are responsible for the pull request. + 4. Release Note: Add `release_note:skip` if this pull request should not automatically get added to release notes for Kibana + 5. Auto Backport: Add `auto-backport` if you'd like your pull request automatically backported to all labeled versions. +6. Submit the pull request. If it's not quite ready for review, it can also be submitted as a Draft pull request. + +![Screenshot of Compare and pull request header](../assets/pr_header.png) + +![Screenshot of opening a pull request from the branch page](../assets/pr_open.png) + +![Screenshot of pull request details](../assets/pr_details.png) + +## Sign the Contributor Agreement + +If this is your first pull request, a bot will post a comment asking you to sign our [CLA / Contributor Agreement](https://www.elastic.co/contributor-agreement). Your pull request won't be able to be merged until you've reviewed and signed the agreement. + +## Review Process + +At this point, your pull request will be reviewed, discussed, etc. Changes will likely be requested. For complex pull requests, this process could take several weeks. Please be patient and understand we hold our code base to a high standard. + +See [Pull request review guidelines](https://www.elastic.co/guide/en/kibana/master/pr-review.html) for our general philosophy for pull request reviews. + +## Updating your PR with upstream + +If your pull request hasn't been updated with the latest code from the upstream/target branch, e.g. `master`, in the last 48 hours, it won't be able to merge until it is updated. This is to help prevent problems that could occur by merging stale code into upstream, e.g. something new was recently merged that is incompatible with something in your pull request. + +As an alternative to using `git` to manually update your branch, you can leave a comment on your pull request with the text `@elasticmachine merge upstream`. This will automatically update your branch and kick off CI for it. + +## Re-triggering CI + +The easiest way to re-trigger CI is to simply update your branch (see above) with the latest code from upstream. This has the added benefit of ensuring that your branch is up-to-date and compatible. diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index b4be27eee5ed2d..ffc918af925144 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -256,6 +256,10 @@ In general this plugin provides: |The Usage Collection Service defines a set of APIs for other plugins to report the usage of their features. At the same time, it provides necessary the APIs for other services (i.e.: telemetry, monitoring, ...) to consume that usage data. +|{kib-repo}blob/{branch}/src/plugins/user_setup/README.md[userSetup] +|The plugin provides UI and APIs for the interactive setup mode. + + |{kib-repo}blob/{branch}/src/plugins/vis_default_editor/README.md[visDefaultEditor] |The default editor is used in most primary visualizations, e.x. Area, Data table, Pie, etc. It acts as a container for a particular visualization and options tabs. Contains the default "Data" tab in public/components/sidebar/data_tab.tsx. diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index 3c463da842faa5..b3606b122d750e 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -80,7 +80,7 @@ browser so that it does not block pop-up windows or create an exception for your For more information about the {anomaly-detect} feature, see https://www.elastic.co/what-is/elastic-stack-machine-learning[{ml-cap} in the {stack}] -and {ml-docs}/xpack-ml.html[{ml-cap} {anomaly-detect}]. +and {ml-docs}/ml-ad-overview.html[{ml-cap} {anomaly-detect}]. [[xpack-ml-dfanalytics]] == {dfanalytics-cap} diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index faa980fe833cb2..5506e7ab375a2f 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -65,6 +65,10 @@ image::user/security/images/kibana-login.png["Login Selector UI"] For more information, refer to <>. +TIP: If you have multiple authentication providers configured, you can use the `auth_provider_hint` URL query parameter to create a deep +link to any provider and bypass the Login Selector UI. Using the `kibana.yml` above as an example, you can add `?auth_provider_hint=basic1` +to the login page URL, which will take you directly to the basic login page. + [[basic-authentication]] ==== Basic authentication diff --git a/examples/locator_explorer/public/app.tsx b/examples/locator_explorer/public/app.tsx index 440e16302dff9a..8e38c097a847eb 100644 --- a/examples/locator_explorer/public/app.tsx +++ b/examples/locator_explorer/public/app.tsx @@ -19,7 +19,7 @@ import { EuiFieldText } from '@elastic/eui'; import { EuiPageHeader } from '@elastic/eui'; import { EuiLink } from '@elastic/eui'; import { AppMountParameters } from '../../../src/core/public'; -import { SharePluginSetup } from '../../../src/plugins/share/public'; +import { formatSearchParams, SharePluginSetup } from '../../../src/plugins/share/public'; import { HelloLocatorV1Params, HelloLocatorV2Params, @@ -34,6 +34,7 @@ interface MigratedLink { linkText: string; link: string; version: string; + params: HelloLocatorV1Params | HelloLocatorV2Params; } const ActionsExplorer = ({ share }: Props) => { @@ -93,6 +94,7 @@ const ActionsExplorer = ({ share }: Props) => { linkText: savedLink.linkText, link, version: savedLink.version, + params: savedLink.params, } as MigratedLink; }) ); @@ -157,7 +159,24 @@ const ActionsExplorer = ({ share }: Props) => { target="_blank" > {link.linkText} + {' '} + ( + + through redirect app + )
)) diff --git a/packages/kbn-dev-utils/src/certs.ts b/packages/kbn-dev-utils/src/certs.ts index ca1e2d69b13295..9d1a6077d53c14 100644 --- a/packages/kbn-dev-utils/src/certs.ts +++ b/packages/kbn-dev-utils/src/certs.ts @@ -8,7 +8,7 @@ import { resolve } from 'path'; -export const CA_CERT_PATH = resolve(__dirname, '../certs/ca.crt'); +export const CA_CERT_PATH = process.env.TEST_CA_CERT_PATH || resolve(__dirname, '../certs/ca.crt'); export const ES_KEY_PATH = resolve(__dirname, '../certs/elasticsearch.key'); export const ES_CERT_PATH = resolve(__dirname, '../certs/elasticsearch.crt'); export const ES_P12_PATH = resolve(__dirname, '../certs/elasticsearch.p12'); diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 6627b644daec76..2c7f194d7da985 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -112,3 +112,4 @@ pageLoadAssetSize: visTypePie: 35583 expressionRevealImage: 25675 cases: 144442 + userSetup: 18532 diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 6bb714e9138383..305a06e60bc0b2 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -231,7 +231,7 @@ export class DocLinksService { ml: { guide: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/index.html`, aggregations: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-aggregation.html`, - anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/xpack-ml.html`, + anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-overview.html`, anomalyDetectionJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-jobs.html`, anomalyDetectionConfiguringCategories: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-categories.html`, anomalyDetectionBucketSpan: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#bucket-span`, @@ -249,7 +249,7 @@ export class DocLinksService { customUrls: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-url.html`, dataFrameAnalytics: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics.html`, featureImportance: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-feature-importance.html`, - outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-roc`, + outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-finding-outliers.html#ml-dfanalytics-roc`, regressionEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-regression-evaluation`, classificationAucRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-class-aucroc`, }, diff --git a/src/core/public/http/fetch.ts b/src/core/public/http/fetch.ts index 345fcecbda445c..87df54f2c6a8a4 100644 --- a/src/core/public/http/fetch.ts +++ b/src/core/public/http/fetch.ts @@ -30,6 +30,7 @@ interface Params { const JSON_CONTENT = /^(application\/(json|x-javascript)|text\/(x-)?javascript|x-json)(;.*)?$/; const NDJSON_CONTENT = /^(application\/ndjson)(;.*)?$/; +const ZIP_CONTENT = /^(application\/zip)(;.*)?$/; const removedUndefined = (obj: Record | undefined) => { return omitBy(obj, (v) => v === undefined); @@ -153,7 +154,7 @@ export class Fetch { const contentType = response.headers.get('Content-Type') || ''; try { - if (NDJSON_CONTENT.test(contentType)) { + if (NDJSON_CONTENT.test(contentType) || ZIP_CONTENT.test(contentType)) { body = await response.blob(); } else if (JSON_CONTENT.test(contentType)) { body = await response.json(); diff --git a/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat index 9221af3142e613..1b065dd785d847 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-encryption-keys.bat @@ -13,7 +13,7 @@ If Not Exist "%NODE%" ( ) set CONFIG_DIR=%KBN_PATH_CONF% -If [%KBN_PATH_CONF%] == [] ( +If ["%KBN_PATH_CONF%"] == [] ( set "CONFIG_DIR=%DIR%\config" ) diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat index c40145e7d68171..11925dc4e70ed3 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat @@ -13,7 +13,7 @@ If Not Exist "%NODE%" ( ) set CONFIG_DIR=%KBN_PATH_CONF% -If [%KBN_PATH_CONF%] == [] ( +If ["%KBN_PATH_CONF%"] == [] ( set "CONFIG_DIR=%DIR%\config" ) diff --git a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat index d1282f8cf32ac5..169895082b0c7c 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat @@ -14,7 +14,7 @@ If Not Exist "%NODE%" ( ) set CONFIG_DIR=%KBN_PATH_CONF% -If [%KBN_PATH_CONF%] == [] ( +If ["%KBN_PATH_CONF%"] == [] ( set "CONFIG_DIR=%DIR%\config" ) diff --git a/src/dev/build/tasks/bin/scripts/kibana.bat b/src/dev/build/tasks/bin/scripts/kibana.bat index 4fc62804ca9a13..2b2ce84ebb9275 100755 --- a/src/dev/build/tasks/bin/scripts/kibana.bat +++ b/src/dev/build/tasks/bin/scripts/kibana.bat @@ -15,7 +15,7 @@ If Not Exist "%NODE%" ( ) set CONFIG_DIR=%KBN_PATH_CONF% -If [%KBN_PATH_CONF%] == [] ( +If ["%KBN_PATH_CONF%"] == [] ( set "CONFIG_DIR=%DIR%\config" ) diff --git a/src/plugins/data/server/autocomplete/terms_agg.test.ts b/src/plugins/data/server/autocomplete/terms_agg.test.ts index e4652c2c422e22..ae991e289a7159 100644 --- a/src/plugins/data/server/autocomplete/terms_agg.test.ts +++ b/src/plugins/data/server/autocomplete/terms_agg.test.ts @@ -32,6 +32,8 @@ const mockResponse = { }, } as ApiResponse>; +jest.mock('../index_patterns'); + describe('terms agg suggestions', () => { beforeEach(() => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -86,4 +88,50 @@ describe('terms agg suggestions', () => { ] `); }); + + it('calls the _search API with a terms agg and fallback to fieldName when field is null', async () => { + const result = await termsAggSuggestions( + configMock, + savedObjectsClientMock, + esClientMock, + 'index', + 'fieldName', + 'query', + [] + ); + + const [[args]] = esClientMock.search.mock.calls; + + expect(args).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "suggestions": Object { + "terms": Object { + "execution_hint": "map", + "field": "fieldName", + "include": "query.*", + "shard_size": 10, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [], + }, + }, + "size": 0, + "terminate_after": 98430, + "timeout": "4513ms", + }, + "index": "index", + } + `); + expect(result).toMatchInlineSnapshot(` + Array [ + "whoa", + "amazing", + ] + `); + }); }); diff --git a/src/plugins/data/server/autocomplete/terms_enum.test.ts b/src/plugins/data/server/autocomplete/terms_enum.test.ts index be8f179db29c05..41eaf3f4032ab0 100644 --- a/src/plugins/data/server/autocomplete/terms_enum.test.ts +++ b/src/plugins/data/server/autocomplete/terms_enum.test.ts @@ -22,6 +22,8 @@ const mockResponse = { body: { terms: ['whoa', 'amazing'] }, }; +jest.mock('../index_patterns'); + describe('_terms_enum suggestions', () => { beforeEach(() => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -71,4 +73,45 @@ describe('_terms_enum suggestions', () => { `); expect(result).toEqual(mockResponse.body.terms); }); + + it('calls the _terms_enum API and fallback to fieldName when field is null', async () => { + const result = await termsEnumSuggestions( + configMock, + savedObjectsClientMock, + esClientMock, + 'index', + 'fieldName', + 'query', + [] + ); + + const [[args]] = esClientMock.transport.request.mock.calls; + + expect(args).toMatchInlineSnapshot(` + Object { + "body": Object { + "field": "fieldName", + "index_filter": Object { + "bool": Object { + "must": Array [ + Object { + "terms": Object { + "_tier": Array [ + "data_hot", + "data_warm", + "data_content", + ], + }, + }, + ], + }, + }, + "string": "query", + }, + "method": "POST", + "path": "/index/_terms_enum", + } + `); + expect(result).toEqual(mockResponse.body.terms); + }); }); diff --git a/src/plugins/data/server/autocomplete/terms_enum.ts b/src/plugins/data/server/autocomplete/terms_enum.ts index c2452b0a099d04..40329586a36218 100644 --- a/src/plugins/data/server/autocomplete/terms_enum.ts +++ b/src/plugins/data/server/autocomplete/terms_enum.ts @@ -36,7 +36,7 @@ export async function termsEnumSuggestions( method: 'POST', path: encodeURI(`/${index}/_terms_enum`), body: { - field: field?.name ?? field, + field: field?.name ?? fieldName, string: query, index_filter: { bool: { diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 65857f02c883d9..54a3fe9e4399c0 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -129,6 +129,7 @@ export const applicationUsageSchema = { error: commonSchema, status: commonSchema, kibanaOverview: commonSchema, + r: commonSchema, // X-Pack apm: commonSchema, diff --git a/src/plugins/kibana_utils/common/persistable_state/index.ts b/src/plugins/kibana_utils/common/persistable_state/index.ts index 809cb15c3e9606..18f59186f61831 100644 --- a/src/plugins/kibana_utils/common/persistable_state/index.ts +++ b/src/plugins/kibana_utils/common/persistable_state/index.ts @@ -6,87 +6,5 @@ * Side Public License, v 1. */ -import { SavedObjectReference } from '../../../../core/types'; - -export type SerializableValue = string | number | boolean | null | undefined | SerializableState; -export type Serializable = SerializableValue | SerializableValue[]; - -export type SerializableState = { - [key: string]: Serializable; -}; - -export type MigrateFunction< - FromVersion extends SerializableState = SerializableState, - ToVersion extends SerializableState = SerializableState -> = (state: FromVersion) => ToVersion; - -export type MigrateFunctionsObject = { - [key: string]: MigrateFunction; -}; - -export interface PersistableStateService

{ - /** - * function to extract telemetry information - * @param state - * @param collector - */ - telemetry: (state: P, collector: Record) => Record; - /** - * inject function receives state and a list of references and should return state with references injected - * default is identity function - * @param state - * @param references - */ - inject: (state: P, references: SavedObjectReference[]) => P; - /** - * extract function receives state and should return state with references extracted and array of references - * default returns same state with empty reference array - * @param state - */ - extract: (state: P) => { state: P; references: SavedObjectReference[] }; - - /** - * migrateToLatest function receives state of older version and should migrate to the latest version - * @param state - * @param version - */ - migrateToLatest?: (state: SerializableState, version: string) => P; - - /** - * migrate function runs the specified migration - * @param state - * @param version - */ - migrate: (state: SerializableState, version: string) => SerializableState; -} - -export interface PersistableState

{ - /** - * function to extract telemetry information - * @param state - * @param collector - */ - telemetry: (state: P, collector: Record) => Record; - /** - * inject function receives state and a list of references and should return state with references injected - * default is identity function - * @param state - * @param references - */ - inject: (state: P, references: SavedObjectReference[]) => P; - /** - * extract function receives state and should return state with references extracted and array of references - * default returns same state with empty reference array - * @param state - */ - extract: (state: P) => { state: P; references: SavedObjectReference[] }; - - /** - * list of all migrations per semver - */ - migrations: MigrateFunctionsObject; -} - -export type PersistableStateDefinition

= Partial< - PersistableState

->; +export * from './types'; +export { migrateToLatest } from './migrate_to_latest'; diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts new file mode 100644 index 00000000000000..2ae376e787d2f7 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright 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 { SerializableState, MigrateFunction } from './types'; +import { migrateToLatest } from './migrate_to_latest'; + +interface StateV1 extends SerializableState { + name: string; +} + +interface StateV2 extends SerializableState { + firstName: string; + lastName: string; +} + +interface StateV3 extends SerializableState { + firstName: string; + lastName: string; + isAdmin: boolean; + age: number; +} + +const migrationV2: MigrateFunction = ({ name }) => { + return { + firstName: name, + lastName: '', + }; +}; + +const migrationV3: MigrateFunction = ({ firstName, lastName }) => { + return { + firstName, + lastName, + isAdmin: false, + age: 0, + }; +}; + +test('returns the same object if there are no migrations to be applied', () => { + const migrated = migrateToLatest( + {}, + { + state: { name: 'Foo' }, + version: '0.0.1', + } + ); + + expect(migrated).toEqual({ + state: { name: 'Foo' }, + version: '0.0.1', + }); +}); + +test('applies a single migration', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.0.2': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.0.1', + } + ); + + expect(newState).toEqual({ + firstName: 'Foo', + lastName: '', + }); + expect(newVersion).toEqual('0.0.2'); +}); + +test('does not apply migration if it has the same version as state', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.0.54': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.0.54', + } + ); + + expect(newState).toEqual({ + name: 'Foo', + }); + expect(newVersion).toEqual('0.0.54'); +}); + +test('does not apply migration if it has lower version', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '0.2.2': (migrationV2 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '0.3.1', + } + ); + + expect(newState).toEqual({ + name: 'Foo', + }); + expect(newVersion).toEqual('0.3.1'); +}); + +test('applies two migrations consecutively', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '7.14.0': (migrationV2 as unknown) as MigrateFunction, + '7.14.2': (migrationV3 as unknown) as MigrateFunction, + }, + { + state: { name: 'Foo' }, + version: '7.13.4', + } + ); + + expect(newState).toEqual({ + firstName: 'Foo', + lastName: '', + isAdmin: false, + age: 0, + }); + expect(newVersion).toEqual('7.14.2'); +}); + +test('applies only migrations which are have higher semver version', () => { + const { state: newState, version: newVersion } = migrateToLatest( + { + '7.14.0': (migrationV2 as unknown) as MigrateFunction, // not applied + '7.14.1': (() => ({})) as MigrateFunction, // not applied + '7.14.2': (migrationV3 as unknown) as MigrateFunction, + }, + { + state: { firstName: 'FooBar', lastName: 'Baz' }, + version: '7.14.1', + } + ); + + expect(newState).toEqual({ + firstName: 'FooBar', + lastName: 'Baz', + isAdmin: false, + age: 0, + }); + expect(newVersion).toEqual('7.14.2'); +}); diff --git a/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.ts new file mode 100644 index 00000000000000..c16392164e3e4a --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/migrate_to_latest.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 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 { compare } from 'semver'; +import { SerializableState, VersionedState, MigrateFunctionsObject } from './types'; + +export function migrateToLatest( + migrations: MigrateFunctionsObject, + { state, version: oldVersion }: VersionedState +): VersionedState { + const versions = Object.keys(migrations || {}) + .filter((v) => compare(v, oldVersion) > 0) + .sort(compare); + + if (!versions.length) return { state, version: oldVersion } as VersionedState; + + for (const version of versions) { + state = migrations[version]!(state); + } + + return { + state: state as S, + version: versions[versions.length - 1], + }; +} diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts new file mode 100644 index 00000000000000..f7168b46e7fca6 --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -0,0 +1,180 @@ +/* + * Copyright 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 { SavedObjectReference } from '../../../../core/types'; + +/** + * Serializable state is something is a POJO JavaScript object that can be + * serialized to a JSON string. + */ +export type SerializableState = { + [key: string]: Serializable; +}; +export type SerializableValue = string | number | boolean | null | undefined | SerializableState; +export type Serializable = SerializableValue | SerializableValue[]; + +/** + * Versioned state is a POJO JavaScript object that can be serialized to JSON, + * and which also contains the version information. The version is stored in + * semver format and corresponds to the Kibana release version when the object + * was created. The version can be used to apply migrations to the object. + * + * For example: + * + * ```ts + * const obj: VersionedState<{ dashboardId: string }> = { + * version: '7.14.0', + * state: { + * dashboardId: '123', + * }, + * }; + * ``` + */ +export interface VersionedState { + version: string; + state: S; +} + +/** + * Persistable state interface can be implemented by something that persists + * (stores) state, for example, in a saved object. Once implemented that thing + * will gain ability to "extract" and "inject" saved object references, which + * are necessary for various saved object tasks, such as export. It will also be + * able to do state migrations across Kibana versions, if the shape of the state + * would change over time. + * + * @todo Maybe rename it to `PersistableStateItem`? + */ +export interface PersistableState

{ + /** + * Function which reports telemetry information. This function is essentially + * a "reducer" - it receives the existing "stats" object and returns an + * updated version of the "stats" object. + * + * @param state The persistable state serializable state object. + * @param stats Stats object containing the stats which were already + * collected. This `stats` object shall not be mutated in-line. + * @returns A new stats object augmented with new telemetry information. + */ + telemetry: (state: P, stats: Record) => Record; + + /** + * A function which receives state and a list of references and should return + * back the state with references injected. The default is an identity + * function. + * + * @param state The persistable state serializable state object. + * @param references List of saved object references. + * @returns Persistable state object with references injected. + */ + inject: (state: P, references: SavedObjectReference[]) => P; + + /** + * A function which receives state and should return the state with references + * extracted and an array of the extracted references. The default case could + * simply return the same state with an empty array of references. + * + * @param state The persistable state serializable state object. + * @returns Persistable state object with references extracted and a list of + * references. + */ + extract: (state: P) => { state: P; references: SavedObjectReference[] }; + + /** + * A list of migration functions, which migrate the persistable state + * serializable object to the next version. Migration functions should are + * keyed by the Kibana version using semver, where the version indicates to + * which version the state will be migrated to. + */ + migrations: MigrateFunctionsObject; +} + +/** + * Collection of migrations that a given type of persistable state object has + * accumulated over time. Migration functions are keyed using semver version + * of Kibana releases. + */ +export type MigrateFunctionsObject = { [semver: string]: MigrateFunction }; +export type MigrateFunction< + FromVersion extends SerializableState = SerializableState, + ToVersion extends SerializableState = SerializableState +> = (state: FromVersion) => ToVersion; + +/** + * @todo Shall we remove this? + */ +export type PersistableStateDefinition

= Partial< + PersistableState

+>; + +/** + * @todo Add description. + */ +export interface PersistableStateService

{ + /** + * Function which reports telemetry information. This function is essentially + * a "reducer" - it receives the existing "stats" object and returns an + * updated version of the "stats" object. + * + * @param state The persistable state serializable state object. + * @param stats Stats object containing the stats which were already + * collected. This `stats` object shall not be mutated in-line. + * @returns A new stats object augmented with new telemetry information. + */ + telemetry(state: P, collector: Record): Record; + + /** + * A function which receives state and a list of references and should return + * back the state with references injected. The default is an identity + * function. + * + * @param state The persistable state serializable state object. + * @param references List of saved object references. + * @returns Persistable state object with references injected. + */ + inject(state: P, references: SavedObjectReference[]): P; + + /** + * A function which receives state and should return the state with references + * extracted and an array of the extracted references. The default case could + * simply return the same state with an empty array of references. + * + * @param state The persistable state serializable state object. + * @returns Persistable state object with references extracted and a list of + * references. + */ + extract(state: P): { state: P; references: SavedObjectReference[] }; + + /** + * Migrate function runs a specified migration of a {@link PersistableState} + * item. + * + * When using this method it is up to consumer to make sure that the + * migration function are executed in the right semver order. To avoid such + * potentially error prone complexity, prefer using `migrateToLatest` method + * instead. + * + * @param state The old persistable state serializable state object, which + * needs a migration. + * @param version Semver version of the migration to execute. + * @returns Persistable state object updated with the specified migration + * applied to it. + */ + migrate(state: SerializableState, version: string): SerializableState; + + /** + * A function which receives the state of an older object and version and + * should migrate the state of the object to the latest possible version using + * the `.migrations` dictionary provided on a {@link PersistableState} item. + * + * @param state The persistable state serializable state object. + * @param version Current semver version of the `state`. + * @returns A serializable state object migrated to the latest state. + */ + migrateToLatest?: (state: VersionedState) => VersionedState

; +} diff --git a/src/plugins/share/common/mocks.ts b/src/plugins/share/common/mocks.ts new file mode 100644 index 00000000000000..6768c1aff810a3 --- /dev/null +++ b/src/plugins/share/common/mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './url_service/mocks'; diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index 680fb2231fc48d..bae57b6d8a31d2 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -30,7 +30,7 @@ export interface LocatorDependencies { getUrl: (location: KibanaLocation, getUrlParams: LocatorGetUrlParams) => Promise; } -export class Locator

implements PersistableState

, LocatorPublic

{ +export class Locator

implements LocatorPublic

{ public readonly migrations: PersistableState

['migrations']; constructor( diff --git a/src/plugins/share/common/url_service/mocks.ts b/src/plugins/share/common/url_service/mocks.ts new file mode 100644 index 00000000000000..be86cfe4017133 --- /dev/null +++ b/src/plugins/share/common/url_service/mocks.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 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. + */ + +/* eslint-disable max-classes-per-file */ + +import type { LocatorDefinition, KibanaLocation } from '.'; +import { UrlService } from '.'; + +export class MockUrlService extends UrlService { + constructor() { + super({ + navigate: async () => {}, + getUrl: async ({ app, path }, { absolute }) => { + return `${absolute ? 'https://example.com' : ''}/app/${app}${path}`; + }, + }); + } +} + +export class MockLocatorDefinition implements LocatorDefinition { + constructor(public readonly id: string) {} + + public readonly getLocation = async (): Promise => { + return { + app: 'test', + path: '/test', + state: { + foo: 'bar', + }, + }; + }; +} diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 5ee3156534c5ef..1f999b59ddb617 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -9,6 +9,7 @@ export { CSV_QUOTE_VALUES_SETTING, CSV_SEPARATOR_SETTING } from '../common/constants'; export { LocatorDefinition, LocatorPublic, KibanaLocation } from '../common/url_service'; +export { parseSearchParams, formatSearchParams } from './url_service'; export { UrlGeneratorStateMapping } from './url_generators/url_generator_definition'; diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts new file mode 100644 index 00000000000000..eb9c6d0d109063 --- /dev/null +++ b/src/plugins/share/public/mocks.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from '../common/mocks'; diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 893108b56bcfad..adc28556d7a3cc 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -19,6 +19,7 @@ import { UrlGeneratorsStart, } from './url_generators/url_generator_service'; import { UrlService } from '../common/url_service'; +import { RedirectManager } from './url_service'; export interface ShareSetupDependencies { securityOss?: SecurityOssPluginSetup; @@ -86,6 +87,11 @@ export class SharePlugin implements Plugin { }, }); + const redirectManager = new RedirectManager({ + url: this.url, + }); + redirectManager.registerRedirectApp(core); + return { ...this.shareMenuRegistry.setup(), urlGenerators: this.urlGeneratorsService.setup(core), diff --git a/src/plugins/share/public/url_service/index.ts b/src/plugins/share/public/url_service/index.ts new file mode 100644 index 00000000000000..8fa88e9c570bd4 --- /dev/null +++ b/src/plugins/share/public/url_service/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './redirect'; diff --git a/src/plugins/share/public/url_service/redirect/README.md b/src/plugins/share/public/url_service/redirect/README.md new file mode 100644 index 00000000000000..cd31f2b80099be --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/README.md @@ -0,0 +1,18 @@ +# Redirect endpoint + +This folder contains implementation of *the Redirect Endpoint*. The Redirect +Endpoint receives parameters of a locator and then "redirects" the user using +navigation without page refresh to the location targeted by the locator. While +using the locator, it is also possible to set the *location state* of the +target page. Location state is a serializable object which can be passed to +the destination app while navigating without a page reload. + +``` +/app/r?l=MY_LOCATOR&v=7.14.0&p=(dashboardId:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) +``` + +For example: + +``` +/app/r?l=DISCOVER_APP_LOCATOR&v=7.14.0&p={%22indexPatternId%22:%22d3d7af60-4c81-11e8-b3d7-01146121b73d%22} +``` diff --git a/src/plugins/share/public/url_service/redirect/components/error.tsx b/src/plugins/share/public/url_service/redirect/components/error.tsx new file mode 100644 index 00000000000000..716848427c638a --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/error.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 * as React from 'react'; +import { + EuiEmptyPrompt, + EuiCallOut, + EuiCodeBlock, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const defaultTitle = i18n.translate('share.urlService.redirect.components.Error.title', { + defaultMessage: 'Redirection error', + description: + 'Title displayed to user in redirect endpoint when redirection cannot be performed successfully.', +}); + +export interface ErrorProps { + title?: string; + error: Error; +} + +export const Error: React.FC = ({ title = defaultTitle, error }) => { + return ( + {title}} + body={ + + + + {error.message} + + + + + {error.stack ? error.stack : ''} + + + } + /> + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/components/page.tsx b/src/plugins/share/public/url_service/redirect/components/page.tsx new file mode 100644 index 00000000000000..805213b73fdd06 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/page.tsx @@ -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 * as React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiPageTemplate } from '@elastic/eui'; +import { Error } from './error'; +import { RedirectManager } from '../redirect_manager'; +import { Spinner } from './spinner'; + +export interface PageProps { + manager: Pick; +} + +export const Page: React.FC = ({ manager }) => { + const error = useObservable(manager.error$); + + if (error) { + return ( + + + + ); + } + + return ( + + + + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/components/spinner.tsx b/src/plugins/share/public/url_service/redirect/components/spinner.tsx new file mode 100644 index 00000000000000..a70ae5eb096aff --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/components/spinner.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 * as React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const text = i18n.translate('share.urlService.redirect.components.Spinner.label', { + defaultMessage: 'Redirecting…', + description: 'Redirect endpoint spinner label.', +}); + +export const Spinner: React.FC = () => { + return ( + + + + + + + + + {text} + + + + + + ); +}; diff --git a/src/plugins/share/public/url_service/redirect/index.ts b/src/plugins/share/public/url_service/redirect/index.ts new file mode 100644 index 00000000000000..8dbc5f4e0ab1c3 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './redirect_manager'; +export { formatSearchParams } from './util/format_search_params'; +export { parseSearchParams } from './util/parse_search_params'; diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts new file mode 100644 index 00000000000000..f610268f529bc9 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.test.ts @@ -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 { RedirectManager } from './redirect_manager'; +import { MockUrlService } from '../../mocks'; +import { MigrateFunction } from 'src/plugins/kibana_utils/common'; + +const setup = () => { + const url = new MockUrlService(); + const locator = url.locators.create({ + id: 'TEST_LOCATOR', + getLocation: async () => { + return { + app: '', + path: '', + state: {}, + }; + }, + migrations: { + '0.0.2': ((({ num }: { num: number }) => ({ num: num * 2 })) as unknown) as MigrateFunction, + }, + }); + const manager = new RedirectManager({ + url, + }); + + return { + url, + locator, + manager, + }; +}; + +describe('on page mount', () => { + test('execute locator "navigate" method', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + expect(spy).toHaveBeenCalledTimes(0); + manager.onMount(`l=TEST_LOCATOR&v=0.0.3&p=${encodeURIComponent(JSON.stringify({}))}`); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('passes arguments provided in URL to locator "navigate" method', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + manager.onMount( + `l=TEST_LOCATOR&v=0.0.3&p=${encodeURIComponent( + JSON.stringify({ + foo: 'bar', + }) + )}` + ); + expect(spy).toHaveBeenCalledWith({ + foo: 'bar', + }); + }); + + test('migrates parameters on-the-fly to the latest version', async () => { + const { locator, manager } = setup(); + const spy = jest.spyOn(locator, 'navigate'); + + manager.onMount( + `l=TEST_LOCATOR&v=0.0.1&p=${encodeURIComponent( + JSON.stringify({ + num: 1, + }) + )}` + ); + expect(spy).toHaveBeenCalledWith({ + num: 2, + }); + }); + + test('throws if locator does not exist', async () => { + const { manager } = setup(); + + expect(() => + manager.onMount( + `l=TEST_LOCATOR_WHICH_DOES_NOT_EXIST&v=0.0.3&p=${encodeURIComponent(JSON.stringify({}))}` + ) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator [ID = TEST_LOCATOR_WHICH_DOES_NOT_EXIST] does not exist."` + ); + }); +}); diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts new file mode 100644 index 00000000000000..6148249f5a047a --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.ts @@ -0,0 +1,95 @@ +/* + * Copyright 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 { CoreSetup } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; +import { BehaviorSubject } from 'rxjs'; +import { migrateToLatest } from '../../../../kibana_utils/common'; +import type { SerializableState } from '../../../../kibana_utils/common'; +import type { UrlService } from '../../../common/url_service'; +import { render } from './render'; +import { parseSearchParams } from './util/parse_search_params'; + +export interface RedirectOptions { + /** Locator ID. */ + id: string; + + /** Kibana version when locator params where generated. */ + version: string; + + /** Locator params. */ + params: unknown & SerializableState; +} + +export interface RedirectManagerDependencies { + url: UrlService; +} + +export class RedirectManager { + public readonly error$ = new BehaviorSubject(null); + + constructor(public readonly deps: RedirectManagerDependencies) {} + + public registerRedirectApp(core: CoreSetup) { + core.application.register({ + id: 'r', + title: 'Redirect endpoint', + chromeless: true, + mount: (params) => { + const unmount = render(params.element, { manager: this }); + this.onMount(params.history.location.search); + return () => { + unmount(); + }; + }, + }); + } + + public onMount(urlLocationSearch: string) { + const options = this.parseSearchParams(urlLocationSearch); + const locator = this.deps.url.locators.get(options.id); + + if (!locator) { + const message = i18n.translate('share.urlService.redirect.RedirectManager.locatorNotFound', { + defaultMessage: 'Locator [ID = {id}] does not exist.', + values: { + id: options.id, + }, + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because locator does not exist.', + }); + const error = new Error(message); + this.error$.next(error); + throw error; + } + + const { state: migratedParams } = migrateToLatest(locator.migrations, { + state: options.params, + version: options.version, + }); + + locator + .navigate(migratedParams) + .then() + .catch((error) => { + // eslint-disable-next-line no-console + console.log('Redirect endpoint failed to execute locator redirect.'); + // eslint-disable-next-line no-console + console.error(error); + }); + } + + protected parseSearchParams(urlLocationSearch: string): RedirectOptions { + try { + return parseSearchParams(urlLocationSearch); + } catch (error) { + this.error$.next(error); + throw error; + } + } +} diff --git a/src/plugins/share/public/url_service/redirect/render.ts b/src/plugins/share/public/url_service/redirect/render.ts new file mode 100644 index 00000000000000..2b9c3a50758e4a --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/render.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 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 * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { Page, PageProps } from './components/page'; + +export const render = (container: HTMLElement, props: PageProps) => { + ReactDOM.render(React.createElement(Page, props), container); + + return () => { + ReactDOM.unmountComponentAtNode(container); + }; +}; diff --git a/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts b/src/plugins/share/public/url_service/redirect/util/format_search_params.test.ts new file mode 100644 index 00000000000000..f8d8d6a6295d96 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/format_search_params.test.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 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 { formatSearchParams } from './format_search_params'; +import { parseSearchParams } from './parse_search_params'; + +test('can format typical locator settings as URL path search params', () => { + const search = formatSearchParams({ + id: 'LOCATOR_ID', + version: '7.21.3', + params: { + dashboardId: '123', + mode: 'edit', + }, + }); + + expect(search.get('l')).toBe('LOCATOR_ID'); + expect(search.get('v')).toBe('7.21.3'); + expect(JSON.parse(search.get('p')!)).toEqual({ + dashboardId: '123', + mode: 'edit', + }); +}); + +test('can format and then parse redirect options', () => { + const options = { + id: 'LOCATOR_ID', + version: '7.21.3', + params: { + dashboardId: '123', + mode: 'edit', + }, + }; + const formatted = formatSearchParams(options); + const parsed = parseSearchParams(formatted.toString()); + + expect(parsed).toEqual(options); +}); diff --git a/src/plugins/share/public/url_service/redirect/util/format_search_params.ts b/src/plugins/share/public/url_service/redirect/util/format_search_params.ts new file mode 100644 index 00000000000000..12c6424182a876 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/format_search_params.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 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 { RedirectOptions } from '../redirect_manager'; + +export function formatSearchParams(opts: RedirectOptions): URLSearchParams { + const searchParams = new URLSearchParams(); + + searchParams.set('l', opts.id); + searchParams.set('v', opts.version); + searchParams.set('p', JSON.stringify(opts.params)); + + return searchParams; +} diff --git a/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts b/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts new file mode 100644 index 00000000000000..418e21cfd40532 --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/parse_search_params.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { parseSearchParams } from './parse_search_params'; + +test('parses a well constructed URL path search part', () => { + const res = parseSearchParams(`?l=LOCATOR&v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`); + + expect(res).toEqual({ + id: 'LOCATOR', + version: '0.0.0', + params: { + foo: 'bar', + }, + }); +}); + +test('throws on missing locator ID', () => { + expect(() => + parseSearchParams(`?v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator ID not specified. Specify \\"l\\" search parameter in the URL, which should be an existing locator ID."` + ); + + expect(() => + parseSearchParams(`?l=&v=0.0.0&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator ID not specified. Specify \\"l\\" search parameter in the URL, which should be an existing locator ID."` + ); +}); + +test('throws on missing version', () => { + expect(() => + parseSearchParams(`?l=LOCATOR&v=&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator params version not specified. Specify \\"v\\" search parameter in the URL, which should be the release version of Kibana when locator params were generated."` + ); + + expect(() => + parseSearchParams(`?l=LOCATOR&p=${encodeURIComponent('{"foo":"bar"}')}`) + ).toThrowErrorMatchingInlineSnapshot( + `"Locator params version not specified. Specify \\"v\\" search parameter in the URL, which should be the release version of Kibana when locator params were generated."` + ); +}); + +test('throws on missing params', () => { + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1`)).toThrowErrorMatchingInlineSnapshot( + `"Locator params not specified. Specify \\"p\\" search parameter in the URL, which should be JSON serialized object of locator params."` + ); + + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1&p=`)).toThrowErrorMatchingInlineSnapshot( + `"Locator params not specified. Specify \\"p\\" search parameter in the URL, which should be JSON serialized object of locator params."` + ); +}); + +test('throws if params are not JSON', () => { + expect(() => parseSearchParams(`?l=LOCATOR&v=1.1.1&p=asdf`)).toThrowErrorMatchingInlineSnapshot( + `"Could not parse locator params. Locator params must be serialized as JSON and set at \\"p\\" URL search parameter."` + ); +}); diff --git a/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts b/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts new file mode 100644 index 00000000000000..a60c1d1b68a97a --- /dev/null +++ b/src/plugins/share/public/url_service/redirect/util/parse_search_params.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { SerializableState } from 'src/plugins/kibana_utils/common'; +import { i18n } from '@kbn/i18n'; +import type { RedirectOptions } from '../redirect_manager'; + +/** + * Parses redirect endpoint URL path search parameters. Expects them in the + * following form: + * + * ``` + * /r?l=&v=&p= + * ``` + * + * @param urlSearch Search part of URL path. + * @returns Parsed out locator ID, version, and locator params. + */ +export function parseSearchParams(urlSearch: string): RedirectOptions { + const search = new URLSearchParams(urlSearch); + const id = search.get('l'); + const version = search.get('v'); + const paramsJson = search.get('p'); + + if (!id) { + const message = i18n.translate( + 'share.urlService.redirect.RedirectManager.missingParamLocator', + { + defaultMessage: + 'Locator ID not specified. Specify "l" search parameter in the URL, which should be an existing locator ID.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing locator ID.', + } + ); + throw new Error(message); + } + + if (!version) { + const message = i18n.translate( + 'share.urlService.redirect.RedirectManager.missingParamVersion', + { + defaultMessage: + 'Locator params version not specified. Specify "v" search parameter in the URL, which should be the release version of Kibana when locator params were generated.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing version parameter.', + } + ); + throw new Error(message); + } + + if (!paramsJson) { + const message = i18n.translate('share.urlService.redirect.RedirectManager.missingParamParams', { + defaultMessage: + 'Locator params not specified. Specify "p" search parameter in the URL, which should be JSON serialized object of locator params.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because of missing params parameter.', + }); + throw new Error(message); + } + + let params: unknown & SerializableState; + try { + params = JSON.parse(paramsJson); + } catch { + const message = i18n.translate('share.urlService.redirect.RedirectManager.invalidParamParams', { + defaultMessage: + 'Could not parse locator params. Locator params must be serialized as JSON and set at "p" URL search parameter.', + description: + 'Error displayed to user in redirect endpoint when redirection cannot be performed successfully, because locator parameters could not be parsed as JSON.', + }); + throw new Error(message); + } + + return { + id, + version, + params, + }; +} diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index d11e1cf78c9606..13caa3c33fa827 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -1743,6 +1743,137 @@ } } }, + "r": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "Always `main`" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 90 days" + } + }, + "views": { + "type": "array", + "items": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "The application view being tracked" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application sub view since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application sub view is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 90 days" + } + } + } + } + } + } + }, "apm": { "properties": { "appId": { diff --git a/src/plugins/user_setup/README.md b/src/plugins/user_setup/README.md new file mode 100644 index 00000000000000..61ec964f5bb80c --- /dev/null +++ b/src/plugins/user_setup/README.md @@ -0,0 +1,3 @@ +# `userSetup` plugin + +The plugin provides UI and APIs for the interactive setup mode. diff --git a/src/plugins/user_setup/jest.config.js b/src/plugins/user_setup/jest.config.js new file mode 100644 index 00000000000000..75e355e230c5db --- /dev/null +++ b/src/plugins/user_setup/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: ['/src/plugins/user_setup'], +}; diff --git a/src/plugins/user_setup/kibana.json b/src/plugins/user_setup/kibana.json new file mode 100644 index 00000000000000..192fd42cd3e264 --- /dev/null +++ b/src/plugins/user_setup/kibana.json @@ -0,0 +1,13 @@ +{ + "id": "userSetup", + "owner": { + "name": "Platform Security", + "githubTeam": "kibana-security" + }, + "description": "This plugin provides UI and APIs for the interactive setup mode.", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["userSetup"], + "server": true, + "ui": true +} diff --git a/src/plugins/user_setup/public/app.tsx b/src/plugins/user_setup/public/app.tsx new file mode 100644 index 00000000000000..2b6b7089539723 --- /dev/null +++ b/src/plugins/user_setup/public/app.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui'; +import React from 'react'; + +export const App = () => { + return ( + + + Kibana server is not ready yet. + + + ); +}; diff --git a/src/plugins/user_setup/public/index.ts b/src/plugins/user_setup/public/index.ts new file mode 100644 index 00000000000000..153bc92a0dd087 --- /dev/null +++ b/src/plugins/user_setup/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright 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 { UserSetupPlugin } from './plugin'; + +export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/public/plugin.tsx b/src/plugins/user_setup/public/plugin.tsx new file mode 100644 index 00000000000000..677c27cc456dca --- /dev/null +++ b/src/plugins/user_setup/public/plugin.tsx @@ -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 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 ReactDOM from 'react-dom'; + +import type { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { App } from './app'; + +export class UserSetupPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'userSetup', + title: 'User Setup', + chromeless: true, + mount: (params) => { + ReactDOM.render(, params.element); + return () => ReactDOM.unmountComponentAtNode(params.element); + }, + }); + } + + public start(core: CoreStart) {} +} diff --git a/src/plugins/user_setup/server/config.ts b/src/plugins/user_setup/server/config.ts new file mode 100644 index 00000000000000..b16c51bcbda09d --- /dev/null +++ b/src/plugins/user_setup/server/config.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 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 { TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; + +export type ConfigType = TypeOf; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); diff --git a/src/plugins/user_setup/server/index.ts b/src/plugins/user_setup/server/index.ts new file mode 100644 index 00000000000000..2a43cbbf65c9de --- /dev/null +++ b/src/plugins/user_setup/server/index.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 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 { TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from 'src/core/server'; + +import { ConfigSchema } from './config'; +import { UserSetupPlugin } from './plugin'; + +export const config: PluginConfigDescriptor> = { + schema: ConfigSchema, +}; + +export const plugin = () => new UserSetupPlugin(); diff --git a/src/plugins/user_setup/server/plugin.ts b/src/plugins/user_setup/server/plugin.ts new file mode 100644 index 00000000000000..918c9a20079352 --- /dev/null +++ b/src/plugins/user_setup/server/plugin.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 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 { CoreSetup, CoreStart, Plugin } from 'src/core/server'; + +export class UserSetupPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/src/plugins/user_setup/tsconfig.json b/src/plugins/user_setup/tsconfig.json new file mode 100644 index 00000000000000..d211a70f12df33 --- /dev/null +++ b/src/plugins/user_setup/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["public/**/*", "server/**/*"], + "references": [{ "path": "../../core/tsconfig.json" }] +} diff --git a/test/functional/apps/context/_context_navigation.js b/test/functional/apps/context/_context_navigation.js index 7f72d44c50ea00..2efc145b12561b 100644 --- a/test/functional/apps/context/_context_navigation.js +++ b/test/functional/apps/context/_context_navigation.js @@ -21,7 +21,8 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['common', 'context', 'discover', 'timePicker']); const kibanaServer = getService('kibanaServer'); - describe('discover - context - back navigation', function contextSize() { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104364 + describe.skip('discover - context - back navigation', function contextSize() { before(async function () { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await kibanaServer.uiSettings.update({ 'doc_table:legacy': true }); diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index a09be8b35ba8f6..6a2298ba48cb45 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -32,7 +32,8 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); - describe('context link in discover', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104413 + describe.skip('context link in discover', () => { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await kibanaServer.uiSettings.update({ diff --git a/test/functional/apps/dashboard/saved_search_embeddable.ts b/test/functional/apps/dashboard/saved_search_embeddable.ts index 5bcec338aad1eb..33d015a4c60199 100644 --- a/test/functional/apps/dashboard/saved_search_embeddable.ts +++ b/test/functional/apps/dashboard/saved_search_embeddable.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'timePicker', 'discover']); - describe('dashboard saved search embeddable', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104365 + describe.skip('dashboard saved search embeddable', () => { before(async () => { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index b29b07f9df4e4b..1ca70112c3d1ed 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardName = 'dashboard with filter'; const filterBar = getService('filterBar'); - describe('dashboard view edit mode', function viewEditModeTests() { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104467 + describe.skip('dashboard view edit mode', function viewEditModeTests() { before(async () => { await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index bb75b4441f8802..245b895d75b3a5 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -38,7 +38,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('query', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104409 + describe.skip('query', function () { const queryName1 = 'Query # 1'; it('should show correct time range string by timepicker', async function () { diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts index 338d17ba31ff49..5ab6495686726c 100644 --- a/test/functional/apps/discover/_field_data.ts +++ b/test/functional/apps/discover/_field_data.ts @@ -33,7 +33,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - describe('field data', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104466 + describe.skip('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; await retry.try(async function () { diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 20f2cab907d9bf..29073c5fe4ebb4 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -40,7 +40,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); }); - describe('saved query management component functionality', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/104366 + describe.skip('saved query management component functionality', function () { before(async function () { // set up a query with filters and a time filter log.debug('set up a query with filters to save'); diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx index be3895967d4dc1..5a56b643745376 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/index.tsx @@ -157,9 +157,9 @@ export function DetailView({ errorGroup, urlParams }: Props) { { history.replace({ - ...location, + ...history.location, search: fromQuery({ - ...toQuery(location.search), + ...toQuery(history.location.search), detailTab: key, }), }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts index 30995fbd133977..0e78e44eedf77b 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts @@ -7,30 +7,17 @@ import { i18n } from '@kbn/i18n'; import { IBasePath } from 'kibana/public'; -import { isEmpty } from 'lodash'; import moment from 'moment'; import { APIReturnType } from '../../../../../services/rest/createCallApmApi'; import { getInfraHref } from '../../../../shared/Links/InfraLink'; +import { + Action, + getNonEmptySections, + SectionRecord, +} from '../../../../shared/transaction_action_menu/sections_helper'; type InstaceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; -interface Action { - key: string; - label: string; - href?: string; - onClick?: () => void; - condition: boolean; -} - -interface Section { - key: string; - title?: string; - subtitle?: string; - actions: Action[]; -} - -type SectionRecord = Record; - function getInfraMetricsQuery(timestamp?: string) { if (!timestamp) { return { from: 0, to: 0 }; @@ -189,15 +176,5 @@ export function getMenuSections({ apm: [{ key: 'apm', actions: apmActions }], }; - // Filter out actions that shouldnt be shown and sections without any actions. - return Object.values(sectionRecord) - .map((sections) => - sections - .map((section) => ({ - ...section, - actions: section.actions.filter((action) => action.condition), - })) - .filter((section) => !isEmpty(section.actions)) - ) - .filter((sections) => !isEmpty(sections)); + return getNonEmptySections(sectionRecord); } diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts index 0e30cfe3168f11..ebc48e1e9faf48 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts @@ -17,6 +17,7 @@ import { getDiscoverHref } from '../Links/DiscoverLinks/DiscoverLink'; import { getDiscoverQuery } from '../Links/DiscoverLinks/DiscoverTransactionLink'; import { getInfraHref } from '../Links/InfraLink'; import { fromQuery } from '../Links/url_helpers'; +import { SectionRecord, getNonEmptySections, Action } from './sections_helper'; function getInfraMetricsQuery(transaction: Transaction) { const timestamp = new Date(transaction['@timestamp']).getTime(); @@ -28,22 +29,6 @@ function getInfraMetricsQuery(transaction: Transaction) { }; } -interface Action { - key: string; - label: string; - href: string; - condition: boolean; -} - -interface Section { - key: string; - title?: string; - subtitle?: string; - actions: Action[]; -} - -type SectionRecord = Record; - export const getSections = ({ transaction, basePath, @@ -296,14 +281,5 @@ export const getSections = ({ }; // Filter out actions that shouldnt be shown and sections without any actions. - return Object.values(sectionRecord) - .map((sections) => - sections - .map((section) => ({ - ...section, - actions: section.actions.filter((action) => action.condition), - })) - .filter((section) => !isEmpty(section.actions)) - ) - .filter((sections) => !isEmpty(sections)); + return getNonEmptySections(sectionRecord); }; diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts new file mode 100644 index 00000000000000..741a66d71be14e --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright 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 { getNonEmptySections } from './sections_helper'; + +describe('getNonEmptySections', () => { + it('returns empty when no section is available', () => { + expect(getNonEmptySections({})).toEqual([]); + }); + it("returns empty when section doesn't have actions", () => { + expect( + getNonEmptySections({ + foo: [ + { + key: 'foo', + title: 'Foo', + subtitle: 'Foo bar', + actions: [], + }, + ], + }) + ).toEqual([]); + }); + + it('returns only sections with actions with condition true', () => { + expect( + getNonEmptySections({ + foo: [ + { + key: 'foo', + title: 'Foo', + subtitle: 'Foo bar', + actions: [], + }, + ], + bar: [ + { + key: 'bar', + title: 'Bar', + subtitle: 'Bar foo', + actions: [ + { + key: 'bar_action', + label: 'Bar Action', + condition: true, + }, + { + key: 'bar_action_2', + label: 'Bar Action 2', + condition: false, + }, + ], + }, + ], + }) + ).toEqual([ + [ + { + key: 'bar', + title: 'Bar', + subtitle: 'Bar foo', + actions: [ + { + key: 'bar_action', + label: 'Bar Action', + condition: true, + }, + ], + }, + ], + ]); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts new file mode 100644 index 00000000000000..1632fdb6780131 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections_helper.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; + +export interface Action { + key: string; + label: string; + href?: string; + onClick?: () => void; + condition: boolean; +} + +interface Section { + key: string; + title?: string; + subtitle?: string; + actions: Action[]; +} + +export type SectionRecord = Record; + +/** Filter out actions that shouldnt be shown and sections without any actions. */ +export function getNonEmptySections(sectionRecord: SectionRecord) { + return Object.values(sectionRecord) + .map((sections) => + sections + .map((section) => ({ + ...section, + actions: section.actions.filter((action) => action.condition), + })) + .filter((section) => !isEmpty(section.actions)) + ) + .filter((sections) => !isEmpty(sections)); +} diff --git a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts index 8bfb137c1689cf..60ce36a85235ed 100644 --- a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts @@ -6,7 +6,7 @@ */ import { SearchAggregatedTransactionSetting } from '../../../../common/aggregated_transactions'; -import { rangeQuery } from '../../../../server/utils/queries'; +import { kqlQuery, rangeQuery } from '../../../../server/utils/queries'; import { ProcessorEvent } from '../../../../common/processor_event'; import { TRANSACTION_DURATION, @@ -19,10 +19,12 @@ export async function getHasAggregatedTransactions({ start, end, apmEventClient, + kuery, }: { start?: number; end?: number; apmEventClient: APMEventClient; + kuery?: string; }) { const response = await apmEventClient.search( 'get_has_aggregated_transactions', @@ -36,6 +38,7 @@ export async function getHasAggregatedTransactions({ filter: [ { exists: { field: TRANSACTION_DURATION_HISTOGRAM } }, ...(start && end ? rangeQuery(start, end) : []), + ...kqlQuery(kuery), ], }, }, @@ -56,19 +59,22 @@ export async function getSearchAggregatedTransactions({ start, end, apmEventClient, + kuery, }: { config: APMConfig; start?: number; end?: number; apmEventClient: APMEventClient; + kuery?: string; }): Promise { const searchAggregatedTransactions = config['xpack.apm.searchAggregatedTransactions']; if ( + kuery || searchAggregatedTransactions === SearchAggregatedTransactionSetting.auto ) { - return getHasAggregatedTransactions({ start, end, apmEventClient }); + return getHasAggregatedTransactions({ start, end, apmEventClient, kuery }); } return ( diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 4384d2be78ca04..3329119726bb56 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -51,9 +51,10 @@ const servicesRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { params, logger } = resources; const { environment, kuery } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getServices({ environment, @@ -405,9 +406,10 @@ const serviceThroughputRoute = createApmServerRoute({ comparisonStart, comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const { start, end } = setup; @@ -477,9 +479,10 @@ const serviceInstancesMainStatisticsRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const { start, end } = setup; @@ -552,9 +555,10 @@ const serviceInstancesDetailedStatisticsRoute = createApmServerRoute({ latencyAggregationType, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getServiceInstancesDetailedStatisticsPeriods({ environment, @@ -593,9 +597,10 @@ export const serviceInstancesMetadataDetails = createApmServerRoute({ const { serviceName, serviceNodeName } = resources.params.path; const { transactionType, environment, kuery } = resources.params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return await getServiceInstanceMetadataDetails({ searchAggregatedTransactions, diff --git a/x-pack/plugins/apm/server/routes/traces.ts b/x-pack/plugins/apm/server/routes/traces.ts index 7fce04644f2205..bed7252dd20fda 100644 --- a/x-pack/plugins/apm/server/routes/traces.ts +++ b/x-pack/plugins/apm/server/routes/traces.ts @@ -26,9 +26,10 @@ const tracesRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { params } = resources; const { environment, kuery } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionGroupList( { environment, kuery, type: 'top_traces', searchAggregatedTransactions }, diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index bcc554e552fc33..c20de31847e8a3 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -56,9 +56,10 @@ const transactionGroupsRoute = createApmServerRoute({ const { serviceName } = params.path; const { environment, kuery, transactionType } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionGroupList( { @@ -95,16 +96,16 @@ const transactionGroupsMainStatisticsRoute = createApmServerRoute({ handler: async (resources) => { const { params } = resources; const setup = await setupRequest(resources); - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - const { path: { serviceName }, query: { environment, kuery, latencyAggregationType, transactionType }, } = params; + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); + return getServiceTransactionGroups({ environment, kuery, @@ -140,11 +141,6 @@ const transactionGroupsDetailedStatisticsRoute = createApmServerRoute({ }, handler: async (resources) => { const setup = await setupRequest(resources); - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - const { params } = resources; const { @@ -161,6 +157,11 @@ const transactionGroupsDetailedStatisticsRoute = createApmServerRoute({ }, } = params; + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); + return await getServiceTransactionGroupDetailedStatisticsPeriods({ environment, kuery, @@ -208,9 +209,10 @@ const transactionLatencyChartsRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); const options = { environment, @@ -276,9 +278,10 @@ const transactionThroughputChartsRoute = createApmServerRoute({ transactionName, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return await getThroughputCharts({ environment, @@ -327,9 +330,10 @@ const transactionChartsDistributionRoute = createApmServerRoute({ traceId = '', } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getTransactionDistribution({ environment, @@ -411,9 +415,10 @@ const transactionChartsErrorRateRoute = createApmServerRoute({ comparisonEnd, } = params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); + const searchAggregatedTransactions = await getSearchAggregatedTransactions({ + ...setup, + kuery, + }); return getErrorRatePeriods({ environment, diff --git a/x-pack/plugins/canvas/i18n/errors.ts b/x-pack/plugins/canvas/i18n/errors.ts index a55762dce2d204..8b6697e78ca373 100644 --- a/x-pack/plugins/canvas/i18n/errors.ts +++ b/x-pack/plugins/canvas/i18n/errors.ts @@ -17,30 +17,6 @@ export const ErrorStrings = { }, }), }, - downloadWorkpad: { - getDownloadFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', { - defaultMessage: "Couldn't download workpad", - }), - getDownloadRenderedWorkpadFailureErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage', - { - defaultMessage: "Couldn't download rendered workpad", - } - ), - getDownloadRuntimeFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', { - defaultMessage: "Couldn't download Shareable Runtime", - }), - getDownloadZippedRuntimeFailureErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', - { - defaultMessage: "Couldn't download ZIP file", - } - ), - }, esPersist: { getSaveFailureTitle: () => i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', { diff --git a/x-pack/plugins/canvas/public/components/home/hooks/index.ts b/x-pack/plugins/canvas/public/components/home/hooks/index.ts index c4267a98574906..dde9a06e4851da 100644 --- a/x-pack/plugins/canvas/public/components/home/hooks/index.ts +++ b/x-pack/plugins/canvas/public/components/home/hooks/index.ts @@ -8,7 +8,6 @@ export { useCloneWorkpad } from './use_clone_workpad'; export { useCreateWorkpad } from './use_create_workpad'; export { useDeleteWorkpads } from './use_delete_workpad'; -export { useDownloadWorkpad } from './use_download_workpad'; export { useFindTemplates } from './use_find_templates'; export { useFindWorkpads } from './use_find_workpad'; export { useImportWorkpad } from './use_upload_workpad'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx index e5d83039a87ebe..6d88691f2eabe5 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx @@ -11,7 +11,8 @@ import { useSelector } from 'react-redux'; import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; import type { State } from '../../../../types'; import { usePlatformService } from '../../../services'; -import { useCloneWorkpad, useDownloadWorkpad } from '../hooks'; +import { useCloneWorkpad } from '../hooks'; +import { useDownloadWorkpad } from '../../hooks'; import { WorkpadTable as Component } from './workpad_table.component'; import { WorkpadsContext } from './my_workpads'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx index 62d84adfc2649d..02b4ee61ea0caf 100644 --- a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx @@ -10,7 +10,8 @@ import { useSelector } from 'react-redux'; import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; import type { State } from '../../../../types'; -import { useDeleteWorkpads, useDownloadWorkpad } from '../hooks'; +import { useDeleteWorkpads } from '../hooks'; +import { useDownloadWorkpad } from '../../hooks'; import { WorkpadTableTools as Component, diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts b/x-pack/plugins/canvas/public/components/hooks/index.tsx similarity index 51% rename from x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts rename to x-pack/plugins/canvas/public/components/hooks/index.tsx index b875e08c2a230e..e420ab4cd698c9 100644 --- a/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts +++ b/x-pack/plugins/canvas/public/components/hooks/index.tsx @@ -5,8 +5,4 @@ * 2.0. */ -import { useCallback } from 'react'; -import { downloadWorkpad as downloadWorkpadFn } from '../../../lib/download_workpad'; - -export const useDownloadWorkpad = () => - useCallback((workpadId: string) => downloadWorkpadFn(workpadId), []); +export * from './workpad'; diff --git a/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx b/x-pack/plugins/canvas/public/components/hooks/workpad/index.tsx new file mode 100644 index 00000000000000..50d527036560ad --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/workpad/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 { useDownloadWorkpad, useDownloadRenderedWorkpad } from './use_download_workpad'; diff --git a/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts b/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts new file mode 100644 index 00000000000000..b688bb5a3b1a53 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/hooks/workpad/use_download_workpad.ts @@ -0,0 +1,71 @@ +/* + * Copyright 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 } from 'react'; +import fileSaver from 'file-saver'; +import { i18n } from '@kbn/i18n'; +import { useNotifyService, useWorkpadService } from '../../../services'; +import { CanvasWorkpad } from '../../../../types'; +import { CanvasRenderedWorkpad } from '../../../../shareable_runtime/types'; + +const strings = { + getDownloadFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadFailureErrorMessage', { + defaultMessage: "Couldn't download workpad", + }), + getDownloadRenderedWorkpadFailureErrorMessage: () => + i18n.translate( + 'xpack.canvas.error.downloadWorkpad.downloadRenderedWorkpadFailureErrorMessage', + { + defaultMessage: "Couldn't download rendered workpad", + } + ), +}; + +export const useDownloadWorkpad = () => { + const notifyService = useNotifyService(); + const workpadService = useWorkpadService(); + const download = useDownloadWorkpadBlob(); + + return useCallback( + async (workpadId: string) => { + try { + const workpad = await workpadService.get(workpadId); + + download(workpad, `canvas-workpad-${workpad.name}-${workpad.id}`); + } catch (err) { + notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); + } + }, + [workpadService, notifyService, download] + ); +}; + +export const useDownloadRenderedWorkpad = () => { + const notifyService = useNotifyService(); + const download = useDownloadWorkpadBlob(); + + return useCallback( + async (workpad: CanvasRenderedWorkpad) => { + try { + download(workpad, `canvas-embed-workpad-${workpad.name}-${workpad.id}`); + } catch (err) { + notifyService.error(err, { + title: strings.getDownloadRenderedWorkpadFailureErrorMessage(), + }); + } + }, + [notifyService, download] + ); +}; + +const useDownloadWorkpadBlob = () => { + return useCallback((workpad: CanvasWorkpad | CanvasRenderedWorkpad, filename: string) => { + const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); + fileSaver.saveAs(jsonBlob, `${filename}.json`); + }, []); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx index be337a6dcf00c8..52e80c316c1ef3 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.component.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useCallback } from 'react'; import { EuiText, EuiSpacer, @@ -24,35 +24,21 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { arrayBufferFetch } from '../../../../../common/lib/fetch'; -import { API_ROUTE_SHAREABLE_ZIP } from '../../../../../common/lib/constants'; import { CanvasRenderedWorkpad } from '../../../../../shareable_runtime/types'; -import { - downloadRenderedWorkpad, - downloadRuntime, - downloadZippedRuntime, -} from '../../../../lib/download_workpad'; +import { useDownloadRenderedWorkpad } from '../../../hooks'; +import { useDownloadRuntime, useDownloadZippedRuntime } from './hooks'; import { ZIP, CANVAS, HTML } from '../../../../../i18n/constants'; import { OnCloseFn } from '../share_menu.component'; import { WorkpadStep } from './workpad_step'; import { RuntimeStep } from './runtime_step'; import { SnippetsStep } from './snippets_step'; -import { useNotifyService, usePlatformService } from '../../../../services'; +import { useNotifyService } from '../../../../services'; const strings = { getCopyShareConfigMessage: () => i18n.translate('xpack.canvas.workpadHeaderShareMenu.copyShareConfigMessage', { defaultMessage: 'Copied share markup to clipboard', }), - getShareableZipErrorTitle: (workpadName: string) => - i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { - defaultMessage: - "Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.", - values: { - ZIP, - workpadName, - }, - }), getUnknownExportErrorMessage: (type: string) => i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { defaultMessage: 'Unknown export type: {type}', @@ -121,33 +107,33 @@ export const ShareWebsiteFlyout: FC = ({ renderedWorkpad, }) => { const notifyService = useNotifyService(); - const platformService = usePlatformService(); - const onCopy = () => { - notifyService.info(strings.getCopyShareConfigMessage()); - }; - const onDownload = (type: 'share' | 'shareRuntime' | 'shareZip') => { - switch (type) { - case 'share': - downloadRenderedWorkpad(renderedWorkpad); - return; - case 'shareRuntime': - downloadRuntime(platformService.getBasePath()); - case 'shareZip': - const basePath = platformService.getBasePath(); - arrayBufferFetch - .post(`${basePath}${API_ROUTE_SHAREABLE_ZIP}`, JSON.stringify(renderedWorkpad)) - .then((blob) => downloadZippedRuntime(blob.data)) - .catch((err: Error) => { - notifyService.error(err, { - title: strings.getShareableZipErrorTitle(renderedWorkpad.name), - }); - }); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }; + const onCopy = useCallback(() => notifyService.info(strings.getCopyShareConfigMessage()), [ + notifyService, + ]); + + const downloadRenderedWorkpad = useDownloadRenderedWorkpad(); + const downloadRuntime = useDownloadRuntime(); + const downloadZippedRuntime = useDownloadZippedRuntime(); + + const onDownload = useCallback( + (type: 'share' | 'shareRuntime' | 'shareZip') => { + switch (type) { + case 'share': + downloadRenderedWorkpad(renderedWorkpad); + return; + case 'shareRuntime': + downloadRuntime(); + return; + case 'shareZip': + downloadZippedRuntime(renderedWorkpad); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + [downloadRenderedWorkpad, downloadRuntime, downloadZippedRuntime, renderedWorkpad] + ); const link = ( { @@ -35,12 +34,6 @@ const getUnsupportedRenderers = (state: State) => { return renderers; }; -const mapStateToProps = (state: State) => ({ - renderedWorkpad: getRenderedWorkpad(state), - unsupportedRenderers: getUnsupportedRenderers(state), - workpad: getWorkpad(state), -}); - interface Props { onClose: OnCloseFn; renderedWorkpad: CanvasRenderedWorkpad; @@ -48,14 +41,18 @@ interface Props { workpad: CanvasWorkpad; } -export const ShareWebsiteFlyout = compose>( - connect(mapStateToProps), - withKibana, - withProps( - ({ unsupportedRenderers, renderedWorkpad, onClose, workpad }: Props): ComponentProps => ({ - renderedWorkpad, - unsupportedRenderers, - onClose, - }) - ) -)(Component); +export const ShareWebsiteFlyout: FC> = ({ onClose }) => { + const { renderedWorkpad, unsupportedRenderers } = useSelector((state: State) => ({ + renderedWorkpad: getRenderedWorkpad(state), + unsupportedRenderers: getUnsupportedRenderers(state), + workpad: getWorkpad(state), + })); + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/index.ts new file mode 100644 index 00000000000000..a4243c9fff7e1f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/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 * from './use_download_runtime'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts new file mode 100644 index 00000000000000..dc2e4ff685ca5c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/hooks/use_download_runtime.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import fileSaver from 'file-saver'; +import { i18n } from '@kbn/i18n'; +import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../../../../../common/lib/constants'; +import { ZIP } from '../../../../../../i18n/constants'; + +import { usePlatformService, useNotifyService, useWorkpadService } from '../../../../../services'; +import { CanvasRenderedWorkpad } from '../../../../../../shareable_runtime/types'; + +const strings = { + getDownloadRuntimeFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadRuntimeFailureErrorMessage', { + defaultMessage: "Couldn't download Shareable Runtime", + }), + getDownloadZippedRuntimeFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.downloadWorkpad.downloadZippedRuntimeFailureErrorMessage', { + defaultMessage: "Couldn't download ZIP file", + }), + getShareableZipErrorTitle: (workpadName: string) => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.shareWebsiteErrorTitle', { + defaultMessage: + "Failed to create {ZIP} file for '{workpadName}'. The workpad may be too large. You'll need to download the files separately.", + values: { + ZIP, + workpadName, + }, + }), +}; + +export const useDownloadRuntime = () => { + const platformService = usePlatformService(); + const notifyService = useNotifyService(); + + const downloadRuntime = useCallback(() => { + try { + const path = `${platformService.getBasePath()}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`; + window.open(path); + return; + } catch (err) { + notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); + } + }, [platformService, notifyService]); + + return downloadRuntime; +}; + +export const useDownloadZippedRuntime = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + + const downloadZippedRuntime = useCallback( + (workpad: CanvasRenderedWorkpad) => { + const downloadZip = async () => { + try { + let runtimeZipBlob: Blob | undefined; + try { + runtimeZipBlob = await workpadService.getRuntimeZip(workpad); + } catch (err) { + notifyService.error(err, { + title: strings.getShareableZipErrorTitle(workpad.name), + }); + } + + if (runtimeZipBlob) { + fileSaver.saveAs(runtimeZipBlob, 'canvas-workpad-embed.zip'); + } + } catch (err) { + notifyService.error(err, { + title: strings.getDownloadZippedRuntimeFailureErrorMessage(), + }); + } + }; + + downloadZip(); + }, + [notifyService, workpadService] + ); + return downloadZippedRuntime; +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts deleted file mode 100644 index f514f813599b68..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts +++ /dev/null @@ -1,65 +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 { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; -import { i18n } from '@kbn/i18n'; - -import { CanvasWorkpad, State } from '../../../../types'; -import { downloadWorkpad } from '../../../lib/download_workpad'; -import { withServices, WithServicesProps } from '../../../services'; -import { getPages, getWorkpad } from '../../../state/selectors/workpad'; -import { Props as ComponentProps, ShareMenu as Component } from './share_menu.component'; - -const strings = { - getUnknownExportErrorMessage: (type: string) => - i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { - defaultMessage: 'Unknown export type: {type}', - values: { - type, - }, - }), -}; - -const mapStateToProps = (state: State) => ({ - workpad: getWorkpad(state), - pageCount: getPages(state).length, -}); - -interface Props { - workpad: CanvasWorkpad; - pageCount: number; -} - -export const ShareMenu = compose( - connect(mapStateToProps), - withServices, - withProps( - ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => { - const { - reporting: { start: reporting }, - } = services; - - return { - sharingServices: { reporting }, - sharingData: { workpad, pageCount }, - onExport: (type) => { - switch (type) { - case 'pdf': - // notifications are automatically handled by the Reporting plugin - break; - case 'json': - downloadWorkpad(workpad.id); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }, - }; - } - ) -)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx new file mode 100644 index 00000000000000..0083ff1659c589 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { State } from '../../../../types'; +import { useReportingService } from '../../../services'; +import { getPages, getWorkpad } from '../../../state/selectors/workpad'; +import { useDownloadWorkpad } from '../../hooks'; +import { ShareMenu as ShareMenuComponent } from './share_menu.component'; + +const strings = { + getUnknownExportErrorMessage: (type: string) => + i18n.translate('xpack.canvas.workpadHeaderShareMenu.unknownExportErrorMessage', { + defaultMessage: 'Unknown export type: {type}', + values: { + type, + }, + }), +}; + +export const ShareMenu: FC = () => { + const { workpad, pageCount } = useSelector((state: State) => ({ + workpad: getWorkpad(state), + pageCount: getPages(state).length, + })); + + const reportingService = useReportingService(); + const downloadWorkpad = useDownloadWorkpad(); + + const sharingServices = { + reporting: reportingService.start, + }; + + const sharingData = { + workpad, + pageCount, + }; + + const onExport = useCallback( + (type: string) => { + switch (type) { + case 'pdf': + // notifications are automatically handled by the Reporting plugin + break; + case 'json': + downloadWorkpad(workpad.id); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + [downloadWorkpad, workpad] + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/lib/download_workpad.ts b/x-pack/plugins/canvas/public/lib/download_workpad.ts deleted file mode 100644 index a346de3322d09f..00000000000000 --- a/x-pack/plugins/canvas/public/lib/download_workpad.ts +++ /dev/null @@ -1,64 +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 fileSaver from 'file-saver'; -import { API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD } from '../../common/lib/constants'; -import { ErrorStrings } from '../../i18n'; - -// TODO: clint - convert this whole file to hooks -import { pluginServices } from '../services'; - -// @ts-expect-error untyped local -import * as workpadService from './workpad_service'; -import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; - -const { downloadWorkpad: strings } = ErrorStrings; - -export const downloadWorkpad = async (workpadId: string) => { - try { - const workpad = await workpadService.get(workpadId); - const jsonBlob = new Blob([JSON.stringify(workpad)], { type: 'application/json' }); - fileSaver.saveAs(jsonBlob, `canvas-workpad-${workpad.name}-${workpad.id}.json`); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadFailureErrorMessage() }); - } -}; - -export const downloadRenderedWorkpad = async (renderedWorkpad: CanvasRenderedWorkpad) => { - try { - const jsonBlob = new Blob([JSON.stringify(renderedWorkpad)], { type: 'application/json' }); - fileSaver.saveAs( - jsonBlob, - `canvas-embed-workpad-${renderedWorkpad.name}-${renderedWorkpad.id}.json` - ); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadRenderedWorkpadFailureErrorMessage() }); - } -}; - -export const downloadRuntime = async (basePath: string) => { - try { - const path = `${basePath}${API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD}`; - window.open(path); - return; - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadRuntimeFailureErrorMessage() }); - } -}; - -export const downloadZippedRuntime = async (data: any) => { - try { - const zip = new Blob([data], { type: 'octet/stream' }); - fileSaver.saveAs(zip, 'canvas-workpad-embed.zip'); - } catch (err) { - const notifyService = pluginServices.getServices().notify; - notifyService.error(err, { title: strings.getDownloadZippedRuntimeFailureErrorMessage() }); - } -}; diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js deleted file mode 100644 index 20ad82860f1fa7..00000000000000 --- a/x-pack/plugins/canvas/public/lib/workpad_service.js +++ /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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// TODO: clint - move to workpad service. -import { - API_ROUTE_WORKPAD, - API_ROUTE_WORKPAD_ASSETS, - API_ROUTE_WORKPAD_STRUCTURES, - DEFAULT_WORKPAD_CSS, -} from '../../common/lib/constants'; -import { fetch } from '../../common/lib/fetch'; -import { pluginServices } from '../services'; - -/* - Remove any top level keys from the workpad which will be rejected by validation -*/ -const validKeys = [ - '@created', - '@timestamp', - 'assets', - 'colors', - 'css', - 'variables', - 'height', - 'id', - 'isWriteable', - 'name', - 'page', - 'pages', - 'width', -]; - -const sanitizeWorkpad = function (workpad) { - const workpadKeys = Object.keys(workpad); - - for (const key of workpadKeys) { - if (!validKeys.includes(key)) { - delete workpad[key]; - } - } - - return workpad; -}; - -const getApiPath = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD}`; -}; - -const getApiPathStructures = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD_STRUCTURES}`; -}; - -const getApiPathAssets = function () { - const platformService = pluginServices.getServices().platform; - const basePath = platformService.getBasePath(); - return `${basePath}${API_ROUTE_WORKPAD_ASSETS}`; -}; - -export function create(workpad) { - return fetch.post(getApiPath(), { - ...sanitizeWorkpad({ ...workpad }), - assets: workpad.assets || {}, - variables: workpad.variables || [], - }); -} - -export async function createFromTemplate(templateId) { - return fetch.post(getApiPath(), { - templateId, - }); -} - -export function get(workpadId) { - return fetch.get(`${getApiPath()}/${workpadId}`).then(({ data: workpad }) => { - // shim old workpads with new properties - return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; - }); -} - -// TODO: I think this function is never used. Look into and remove the corresponding route as well -export function update(id, workpad) { - return fetch.put(`${getApiPath()}/${id}`, sanitizeWorkpad({ ...workpad })); -} - -export function updateWorkpad(id, workpad) { - return fetch.put(`${getApiPathStructures()}/${id}`, sanitizeWorkpad({ ...workpad })); -} - -export function updateAssets(id, workpadAssets) { - return fetch.put(`${getApiPathAssets()}/${id}`, workpadAssets); -} - -export function remove(id) { - return fetch.delete(`${getApiPath()}/${id}`); -} - -export function find(searchTerm) { - const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; - - return fetch - .get(`${getApiPath()}/find?name=${validSearchTerm ? searchTerm : ''}&perPage=10000`) - .then(({ data: workpads }) => workpads); -} diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx new file mode 100644 index 00000000000000..3ef93905f7e319 --- /dev/null +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright 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 } from '@testing-library/react-hooks'; +import { useWorkpadPersist } from './use_workpad_persist'; + +const mockGetState = jest.fn(); +const mockUpdateWorkpad = jest.fn(); +const mockUpdateAssets = jest.fn(); +const mockUpdate = jest.fn(); +const mockNotifyError = jest.fn(); + +// Mock the hooks and actions used by the UseWorkpad hook +jest.mock('react-redux', () => ({ + useSelector: (selector: any) => selector(mockGetState()), +})); + +jest.mock('../../../services', () => ({ + useWorkpadService: () => ({ + updateWorkpad: mockUpdateWorkpad, + updateAssets: mockUpdateAssets, + update: mockUpdate, + }), + useNotifyService: () => ({ + error: mockNotifyError, + }), +})); + +describe('useWorkpadPersist', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('initial render does not persist state', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + renderHook(useWorkpadPersist); + + expect(mockUpdateWorkpad).not.toBeCalled(); + expect(mockUpdateAssets).not.toBeCalled(); + expect(mockUpdate).not.toBeCalled(); + }); + + test('changes to workpad cause a workpad update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + ...state, + persistent: { + workpad: { new: 'workpad' }, + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdateWorkpad).toHaveBeenCalled(); + }); + + test('changes to assets cause an asset update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + ...state, + assets: { + asset1: 'some asset', + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdateAssets).toHaveBeenCalled(); + }); + + test('changes to both assets and workpad causes a full update', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + persistent: { + workpad: { new: 'workpad' }, + }, + assets: { + asset1: 'some asset', + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdate).toHaveBeenCalled(); + }); + + test('non changes causes no updated', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + }; + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + rerender(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockUpdateWorkpad).not.toHaveBeenCalled(); + expect(mockUpdateAssets).not.toHaveBeenCalled(); + }); + + test('non write permissions causes no updates', () => { + const state = { + persistent: { + workpad: { some: 'workpad' }, + }, + assets: { + asset1: 'some asset', + asset2: 'other asset', + }, + transient: { + canUserWrite: false, + }, + }; + mockGetState.mockReturnValue(state); + + const { rerender } = renderHook(useWorkpadPersist); + + const newState = { + persistent: { + workpad: { new: 'workpad value' }, + }, + assets: { + asset3: 'something', + }, + transient: { + canUserWrite: false, + }, + }; + mockGetState.mockReturnValue(newState); + + rerender(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockUpdateWorkpad).not.toHaveBeenCalled(); + expect(mockUpdateAssets).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts new file mode 100644 index 00000000000000..62c83e0411848b --- /dev/null +++ b/x-pack/plugins/canvas/public/routes/workpad/hooks/use_workpad_persist.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useCallback } from 'react'; +import { isEqual } from 'lodash'; +import usePrevious from 'react-use/lib/usePrevious'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { CanvasWorkpad, State } from '../../../../types'; +import { getWorkpad, getFullWorkpadPersisted } from '../../../state/selectors/workpad'; +import { canUserWrite } from '../../../state/selectors/app'; +import { getAssetIds } from '../../../state/selectors/assets'; +import { useWorkpadService, useNotifyService } from '../../../services'; + +const strings = { + getSaveFailureTitle: () => + i18n.translate('xpack.canvas.error.esPersist.saveFailureTitle', { + defaultMessage: "Couldn't save your changes to Elasticsearch", + }), + getTooLargeErrorMessage: () => + i18n.translate('xpack.canvas.error.esPersist.tooLargeErrorMessage', { + defaultMessage: + 'The server gave a response that the workpad data was too large. This usually means uploaded image assets that are too large for Kibana or a proxy. Try removing some assets in the asset manager.', + }), + getUpdateFailureTitle: () => + i18n.translate('xpack.canvas.error.esPersist.updateFailureTitle', { + defaultMessage: "Couldn't update workpad", + }), +}; + +export const useWorkpadPersist = () => { + const service = useWorkpadService(); + const notifyService = useNotifyService(); + const notifyError = useCallback( + (err: any) => { + const statusCode = err.response && err.response.status; + switch (statusCode) { + case 400: + return notifyService.error(err.response, { + title: strings.getSaveFailureTitle(), + }); + case 413: + return notifyService.error(strings.getTooLargeErrorMessage(), { + title: strings.getSaveFailureTitle(), + }); + default: + return notifyService.error(err, { + title: strings.getUpdateFailureTitle(), + }); + } + }, + [notifyService] + ); + + // Watch for workpad state or workpad assets to change and then persist those changes + const [workpad, assetIds, fullWorkpad, canWrite]: [ + CanvasWorkpad, + Array, + CanvasWorkpad, + boolean + ] = useSelector((state: State) => [ + getWorkpad(state), + getAssetIds(state), + getFullWorkpadPersisted(state), + canUserWrite(state), + ]); + + const previousWorkpad = usePrevious(workpad); + const previousAssetIds = usePrevious(assetIds); + + const workpadChanged = previousWorkpad && workpad !== previousWorkpad; + const assetsChanged = previousAssetIds && !isEqual(assetIds, previousAssetIds); + + useEffect(() => { + if (canWrite) { + if (workpadChanged && assetsChanged) { + service.update(workpad.id, fullWorkpad).catch(notifyError); + } + if (workpadChanged) { + service.updateWorkpad(workpad.id, workpad).catch(notifyError); + } else if (assetsChanged) { + service.updateAssets(workpad.id, fullWorkpad.assets).catch(notifyError); + } + } + }, [service, workpad, fullWorkpad, workpadChanged, assetsChanged, canWrite, notifyError]); +}; diff --git a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx index 95caba08517ee6..2c1ad4fcb6aa18 100644 --- a/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx +++ b/x-pack/plugins/canvas/public/routes/workpad/workpad_route.tsx @@ -20,6 +20,7 @@ import { useWorkpad } from './hooks/use_workpad'; import { useRestoreHistory } from './hooks/use_restore_history'; import { useWorkpadHistory } from './hooks/use_workpad_history'; import { usePageSync } from './hooks/use_page_sync'; +import { useWorkpadPersist } from './hooks/use_workpad_persist'; import { WorkpadPageRouteProps, WorkpadRouteProps, WorkpadPageRouteParams } from '.'; import { WorkpadRoutingContextComponent } from './workpad_routing_context'; import { WorkpadPresentationHelper } from './workpad_presentation_helper'; @@ -88,6 +89,7 @@ export const WorkpadHistoryManager: FC = ({ children }) => { useRestoreHistory(); useWorkpadHistory(); usePageSync(); + useWorkpadPersist(); return <>{children}; }; diff --git a/x-pack/plugins/canvas/public/services/kibana/workpad.ts b/x-pack/plugins/canvas/public/services/kibana/workpad.ts index 36ad1c568f9e6d..8609d5055cb83c 100644 --- a/x-pack/plugins/canvas/public/services/kibana/workpad.ts +++ b/x-pack/plugins/canvas/public/services/kibana/workpad.ts @@ -14,6 +14,9 @@ import { API_ROUTE_WORKPAD, DEFAULT_WORKPAD_CSS, API_ROUTE_TEMPLATES, + API_ROUTE_WORKPAD_ASSETS, + API_ROUTE_WORKPAD_STRUCTURES, + API_ROUTE_SHAREABLE_ZIP, } from '../../../common/lib/constants'; import { CanvasWorkpad } from '../../../types'; @@ -93,5 +96,25 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ coreStart, remove: (id: string) => { return coreStart.http.delete(`${getApiPath()}/${id}`); }, + update: (id, workpad) => { + return coreStart.http.put(`${getApiPath()}/${id}`, { + body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), + }); + }, + updateWorkpad: (id, workpad) => { + return coreStart.http.put(`${API_ROUTE_WORKPAD_STRUCTURES}/${id}`, { + body: JSON.stringify({ ...sanitizeWorkpad({ ...workpad }) }), + }); + }, + updateAssets: (id, assets) => { + return coreStart.http.put(`${API_ROUTE_WORKPAD_ASSETS}/${id}`, { + body: JSON.stringify(assets), + }); + }, + getRuntimeZip: (workpad) => { + return coreStart.http.post(API_ROUTE_SHAREABLE_ZIP, { + body: JSON.stringify(workpad), + }); + }, }; }; diff --git a/x-pack/plugins/canvas/public/services/legacy/context.tsx b/x-pack/plugins/canvas/public/services/legacy/context.tsx index 2f472afd7d3c1b..fb30a9d418df86 100644 --- a/x-pack/plugins/canvas/public/services/legacy/context.tsx +++ b/x-pack/plugins/canvas/public/services/legacy/context.tsx @@ -26,13 +26,14 @@ const defaultContextValue = { search: {}, }; -const context = createContext(defaultContextValue as CanvasServices); +export const ServicesContext = createContext(defaultContextValue as CanvasServices); -export const useServices = () => useContext(context); +export const useServices = () => useContext(ServicesContext); export const useEmbeddablesService = () => useServices().embeddables; export const useExpressionsService = () => useServices().expressions; export const useNavLinkService = () => useServices().navLink; export const useLabsService = () => useServices().labs; +export const useReportingService = () => useServices().reporting; export const withServices = (type: ComponentType) => { const EnhancedType: FC = (props) => @@ -53,5 +54,5 @@ export const LegacyServicesProvider: FC<{ reporting: specifiedProviders.reporting.getService(), labs: specifiedProviders.labs.getService(), }; - return {children}; + return {children}; }; diff --git a/x-pack/plugins/canvas/public/services/storybook/workpad.ts b/x-pack/plugins/canvas/public/services/storybook/workpad.ts index a494f634141bca..cdf4137e1d84c0 100644 --- a/x-pack/plugins/canvas/public/services/storybook/workpad.ts +++ b/x-pack/plugins/canvas/public/services/storybook/workpad.ts @@ -97,4 +97,18 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = ({ action('workpadService.remove')(id); return Promise.resolve(); }, + update: (id, workpad) => { + action('worpadService.update')(workpad, id); + return Promise.resolve(); + }, + updateWorkpad: (id, workpad) => { + action('workpadService.updateWorkpad')(workpad, id); + return Promise.resolve(); + }, + updateAssets: (id, assets) => { + action('workpadService.updateAssets')(assets, id); + return Promise.resolve(); + }, + getRuntimeZip: (workpad) => + Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })), }); diff --git a/x-pack/plugins/canvas/public/services/stubs/workpad.ts b/x-pack/plugins/canvas/public/services/stubs/workpad.ts index eef7508e7c1eb1..2f2598563d49b1 100644 --- a/x-pack/plugins/canvas/public/services/stubs/workpad.ts +++ b/x-pack/plugins/canvas/public/services/stubs/workpad.ts @@ -96,4 +96,9 @@ export const workpadServiceFactory: CanvasWorkpadServiceFactory = () => ({ createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()), find: findNoWorkpads(), remove: (_id: string) => Promise.resolve(), + update: (id, workpad) => Promise.resolve(), + updateWorkpad: (id, workpad) => Promise.resolve(), + updateAssets: (id, assets) => Promise.resolve(), + getRuntimeZip: (workpad) => + Promise.resolve(new Blob([JSON.stringify(workpad)], { type: 'application/json' })), }); diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 6b90cc346834b0..c0e948669647cb 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -6,6 +6,7 @@ */ import { CanvasWorkpad, CanvasTemplate } from '../../types'; +import { CanvasRenderedWorkpad } from '../../shareable_runtime/types'; export type FoundWorkpads = Array>; export type FoundWorkpad = FoundWorkpads[number]; @@ -24,4 +25,8 @@ export interface CanvasWorkpadService { find: (term: string) => Promise; remove: (id: string) => Promise; findTemplates: () => Promise; + update: (id: string, workpad: CanvasWorkpad) => Promise; + updateWorkpad: (id: string, workpad: CanvasWorkpad) => Promise; + updateAssets: (id: string, assets: CanvasWorkpad['assets']) => Promise; + getRuntimeZip: (workpad: CanvasRenderedWorkpad) => Promise; } diff --git a/x-pack/plugins/canvas/public/state/middleware/es_persist.js b/x-pack/plugins/canvas/public/state/middleware/es_persist.js deleted file mode 100644 index 17d0c9649b9121..00000000000000 --- a/x-pack/plugins/canvas/public/state/middleware/es_persist.js +++ /dev/null @@ -1,99 +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 { isEqual } from 'lodash'; -import { ErrorStrings } from '../../../i18n'; -import { getWorkpad, getFullWorkpadPersisted, getWorkpadPersisted } from '../selectors/workpad'; -import { getAssetIds } from '../selectors/assets'; -import { appReady } from '../actions/app'; -import { setWorkpad, setRefreshInterval, resetWorkpad } from '../actions/workpad'; -import { setAssets, resetAssets } from '../actions/assets'; -import * as transientActions from '../actions/transient'; -import * as resolvedArgsActions from '../actions/resolved_args'; -import { update, updateAssets, updateWorkpad } from '../../lib/workpad_service'; -import { pluginServices } from '../../services'; -import { canUserWrite } from '../selectors/app'; - -const { esPersist: strings } = ErrorStrings; - -const workpadChanged = (before, after) => { - const workpad = getWorkpad(before); - return getWorkpad(after) !== workpad; -}; - -const assetsChanged = (before, after) => { - const assets = getAssetIds(before); - return !isEqual(assets, getAssetIds(after)); -}; - -export const esPersistMiddleware = ({ getState }) => { - // these are the actions we don't want to trigger a persist call - const skippedActions = [ - appReady, // there's no need to resave the workpad once we've loaded it. - resetWorkpad, // used for resetting the workpad in state - setWorkpad, // used for loading and creating workpads - setAssets, // used when loading assets - resetAssets, // used when creating new workpads - setRefreshInterval, // used to set refresh time interval which is a transient value - ...Object.values(resolvedArgsActions), // no resolved args affect persisted values - ...Object.values(transientActions), // no transient actions cause persisted state changes - ].map((a) => a.toString()); - - return (next) => (action) => { - // if the action is in the skipped list, do not persist - if (skippedActions.indexOf(action.type) >= 0) { - return next(action); - } - - // capture state before and after the action - const curState = getState(); - next(action); - const newState = getState(); - - // skips the update request if user doesn't have write permissions - if (!canUserWrite(newState)) { - return; - } - - const notifyError = (err) => { - const statusCode = err.response && err.response.status; - const notifyService = pluginServices.getServices().notify; - - switch (statusCode) { - case 400: - return notifyService.error(err.response, { - title: strings.getSaveFailureTitle(), - }); - case 413: - return notifyService.error(strings.getTooLargeErrorMessage(), { - title: strings.getSaveFailureTitle(), - }); - default: - return notifyService.error(err, { - title: strings.getUpdateFailureTitle(), - }); - } - }; - - const changedWorkpad = workpadChanged(curState, newState); - const changedAssets = assetsChanged(curState, newState); - - if (changedWorkpad && changedAssets) { - // if both the workpad and the assets changed, save it in its entirety to elasticsearch - const persistedWorkpad = getFullWorkpadPersisted(getState()); - return update(persistedWorkpad.id, persistedWorkpad).catch(notifyError); - } else if (changedWorkpad) { - // if the workpad changed, save it to elasticsearch - const persistedWorkpad = getWorkpadPersisted(getState()); - return updateWorkpad(persistedWorkpad.id, persistedWorkpad).catch(notifyError); - } else if (changedAssets) { - // if the assets changed, save it to elasticsearch - const persistedWorkpad = getFullWorkpadPersisted(getState()); - return updateAssets(persistedWorkpad.id, persistedWorkpad.assets).catch(notifyError); - } - }; -}; diff --git a/x-pack/plugins/canvas/public/state/middleware/index.js b/x-pack/plugins/canvas/public/state/middleware/index.js index 713232543fab1e..fbed2fbb3741b3 100644 --- a/x-pack/plugins/canvas/public/state/middleware/index.js +++ b/x-pack/plugins/canvas/public/state/middleware/index.js @@ -8,21 +8,13 @@ import { applyMiddleware, compose as reduxCompose } from 'redux'; import thunkMiddleware from 'redux-thunk'; import { getWindow } from '../../lib/get_window'; -import { esPersistMiddleware } from './es_persist'; import { inFlight } from './in_flight'; import { workpadUpdate } from './workpad_update'; import { elementStats } from './element_stats'; import { resolvedArgs } from './resolved_args'; const middlewares = [ - applyMiddleware( - thunkMiddleware, - elementStats, - resolvedArgs, - esPersistMiddleware, - inFlight, - workpadUpdate - ), + applyMiddleware(thunkMiddleware, elementStats, resolvedArgs, inFlight, workpadUpdate), ]; // compose with redux devtools, if extension is installed diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index e1cebeb65bd219..9cfccf3fc55982 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -7,6 +7,7 @@ import { get, omit } from 'lodash'; import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/common'; +import { CanvasRenderedWorkpad } from '../../../shareable_runtime/types'; import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; import { @@ -500,7 +501,7 @@ export function getRenderedWorkpad(state: State) { return { pages: renderedPages, ...rest, - }; + } as CanvasRenderedWorkpad; } export function getRenderedWorkpadExpressions(state: State) { diff --git a/x-pack/plugins/canvas/shareable_runtime/types.ts b/x-pack/plugins/canvas/shareable_runtime/types.ts index ac8f140b7f11d5..751fb3f795524f 100644 --- a/x-pack/plugins/canvas/shareable_runtime/types.ts +++ b/x-pack/plugins/canvas/shareable_runtime/types.ts @@ -24,15 +24,14 @@ export interface CanvasRenderedElement { * Represents a Page within a Canvas Workpad that is made up of ready-to- * render Elements. */ -export interface CanvasRenderedPage extends Omit, 'groups'> { +export interface CanvasRenderedPage extends Omit { elements: CanvasRenderedElement[]; - groups: CanvasRenderedElement[][]; } /** * A Canvas Workpad made up of ready-to-render Elements. */ -export interface CanvasRenderedWorkpad extends Omit { +export interface CanvasRenderedWorkpad extends Omit { pages: CanvasRenderedPage[]; } diff --git a/x-pack/plugins/canvas/types/state.ts b/x-pack/plugins/canvas/types/state.ts index 6e27093379e31b..cc42839ddfac71 100644 --- a/x-pack/plugins/canvas/types/state.ts +++ b/x-pack/plugins/canvas/types/state.ts @@ -94,7 +94,7 @@ interface PersistentState { export interface State { app: StoreAppState; - assets: { [assetKey: string]: AssetType | undefined }; + assets: { [assetKey: string]: AssetType }; transient: TransientState; persistent: PersistentState; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index 8be6232733defc..5ac594842d3925 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -221,7 +221,7 @@ export const FleetAppContext: React.FC<{ const unlistenParentHistory = history.listen(() => { const newHash = createHashHistory(); if (newHash.location.pathname !== routerHistoryInstance.location.pathname) { - routerHistoryInstance.replace(newHash.location.pathname); + routerHistoryInstance.replace(newHash.location.pathname + newHash.location.search || ''); } }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index 254885ea71b1e4..c0c425447e5567 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -42,7 +42,7 @@ const breadcrumbGetters: { BASE_BREADCRUMB, { text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, ], @@ -50,7 +50,7 @@ const breadcrumbGetters: { BASE_BREADCRUMB, { text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, ], @@ -59,7 +59,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { text: policyName }, @@ -69,7 +69,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { @@ -100,7 +100,7 @@ const breadcrumbGetters: { { href: pagePathGetters.policies()[1], text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { - defaultMessage: 'Policies', + defaultMessage: 'Agent policies', }), }, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx index 7ad034b1cc0595..dd15020adcc752 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default/default.tsx @@ -49,7 +49,7 @@ export const DefaultLayout: React.FunctionComponent = ({ name: ( ), isSelected: section === 'agent_policies', @@ -60,7 +60,7 @@ export const DefaultLayout: React.FunctionComponent = ({ name: ( ), isSelected: section === 'enrollment_tokens', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts index e9e7e09207992b..b4e7982c52f7b5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.test.ts @@ -31,8 +31,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ + ".\\\\elastic-agent.exe install -f \` + --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1" `); }); @@ -78,9 +78,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ - --fleet-server-service-token=service-token-1 \\\\ + ".\\\\elastic-agent.exe install -f \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` --fleet-server-policy=policy-1" `); }); @@ -137,14 +137,14 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - ".\\\\elastic-agent.exe install --url=http://fleetserver:8220 \\\\ - -f \\\\ - --fleet-server-es=http://elasticsearch:9200 \\\\ - --fleet-server-service-token=service-token-1 \\\\ - --fleet-server-policy=policy-1 \\\\ - --certificate-authorities= \\\\ - --fleet-server-es-ca= \\\\ - --fleet-server-cert= \\\\ + ".\\\\elastic-agent.exe install --url=http://fleetserver:8220 \` + -f \` + --fleet-server-es=http://elasticsearch:9200 \` + --fleet-server-service-token=service-token-1 \` + --fleet-server-policy=policy-1 \` + --certificate-authorities= \` + --fleet-server-es-ca= \` + --fleet-server-cert= \` --fleet-server-cert-key=" `); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts index b91c4b60aa7138..e129d7a4d5b4e5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/install_command_utils.ts @@ -16,22 +16,23 @@ export function getInstallCommandForPlatform( isProductionDeployment?: boolean ) { let commandArguments = ''; + const newLineSeparator = platform === 'windows' ? '`' : '\\'; if (isProductionDeployment && fleetServerHost) { - commandArguments += `--url=${fleetServerHost} \\\n`; + commandArguments += `--url=${fleetServerHost} ${newLineSeparator}\n`; } - commandArguments += ` -f \\\n --fleet-server-es=${esHost}`; - commandArguments += ` \\\n --fleet-server-service-token=${serviceToken}`; + commandArguments += ` -f ${newLineSeparator}\n --fleet-server-es=${esHost}`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-service-token=${serviceToken}`; if (policyId) { - commandArguments += ` \\\n --fleet-server-policy=${policyId}`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-policy=${policyId}`; } if (isProductionDeployment) { - commandArguments += ` \\\n --certificate-authorities=`; - commandArguments += ` \\\n --fleet-server-es-ca=`; - commandArguments += ` \\\n --fleet-server-cert=`; - commandArguments += ` \\\n --fleet-server-cert-key=`; + commandArguments += ` ${newLineSeparator}\n --certificate-authorities=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-es-ca=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-cert=`; + commandArguments += ` ${newLineSeparator}\n --fleet-server-cert-key=`; } switch (platform) { diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index cad51a54d70744..ae59d33e44b82f 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -196,7 +196,7 @@ export const IntegrationsAppContext: React.FC<{ const unlistenParentHistory = history.listen(() => { const newHash = createHashHistory(); if (newHash.location.pathname !== routerHistoryInstance.location.pathname) { - routerHistoryInstance.replace(newHash.location.pathname); + routerHistoryInstance.replace(newHash.location.pathname + newHash.location.search || ''); } }); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index cff70737be6ee1..83029833163161 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -773,10 +773,10 @@ class AgentPolicyService { ) { const names: string[] = []; if (fullAgentPolicy.agent.monitoring.logs) { - names.push(`logs-elastic_agent.*-${monitoringNamespace}`); + names.push(`logs-elastic_agent*-${monitoringNamespace}`); } if (fullAgentPolicy.agent.monitoring.metrics) { - names.push(`metrics-elastic_agent.*-${monitoringNamespace}`); + names.push(`metrics-elastic_agent*-${monitoringNamespace}`); } permissions._elastic_agent_checks.indices = [ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts index 9806cdaad637ed..445df21a6067eb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts @@ -106,18 +106,20 @@ export const tinymathFunctions: Record< type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.addFunction.markdown', { + defaultMessage: ` Adds up two numbers. Also works with + symbol Example: Calculate the sum of two fields -${'`sum(price) + sum(tax)`'} +\`sum(price) + sum(tax)\` Example: Offset count by a static value -${'`add(count(), 5)`'} +\`add(count(), 5)\` `, + }), }, subtract: { positionalArguments: [ @@ -130,13 +132,15 @@ ${'`add(count(), 5)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.subtractFunction.markdown', { + defaultMessage: ` Subtracts the first number from the second number. -Also works with ${'`-`'} symbol +Also works with \`-\` symbol Example: Calculate the range of a field -${'`subtract(max(bytes), min(bytes))`'} +\`subtract(max(bytes), min(bytes))\` `, + }), }, multiply: { positionalArguments: [ @@ -149,16 +153,18 @@ ${'`subtract(max(bytes), min(bytes))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.multiplyFunction.markdown', { + defaultMessage: ` Multiplies two numbers. -Also works with ${'`*`'} symbol. +Also works with \`*\` symbol. Example: Calculate price after current tax rate -${'`sum(bytes) * last_value(tax_rate)`'} +\`sum(bytes) * last_value(tax_rate)\` Example: Calculate price after constant tax rate -${'`multiply(sum(price), 1.2)`'} +\`multiply(sum(price), 1.2)\` `, + }), }, divide: { positionalArguments: [ @@ -171,15 +177,17 @@ ${'`multiply(sum(price), 1.2)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.divideFunction.markdown', { + defaultMessage: ` Divides the first number by the second number. -Also works with ${'`/`'} symbol +Also works with \`/\` symbol Example: Calculate profit margin -${'`sum(profit) / sum(revenue)`'} +\`sum(profit) / sum(revenue)\` -Example: ${'`divide(sum(bytes), 2)`'} +Example: \`divide(sum(bytes), 2)\` `, + }), }, abs: { positionalArguments: [ @@ -188,11 +196,13 @@ Example: ${'`divide(sum(bytes), 2)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.absFunction.markdown', { + defaultMessage: ` Calculates absolute value. A negative value is multiplied by -1, a positive value stays the same. -Example: Calculate average distance to sea level ${'`abs(average(altitude))`'} +Example: Calculate average distance to sea level \`abs(average(altitude))\` `, + }), }, cbrt: { positionalArguments: [ @@ -201,12 +211,14 @@ Example: Calculate average distance to sea level ${'`abs(average(altitude))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.cbrtFunction.markdown', { + defaultMessage: ` Cube root of value. Example: Calculate side length from volume -${'`cbrt(last_value(volume))`'} +\`cbrt(last_value(volume))\` `, + }), }, ceil: { positionalArguments: [ @@ -215,13 +227,14 @@ ${'`cbrt(last_value(volume))`'} type: getTypeI18n('number'), }, ], - // signature: 'ceil(value: number)', - help: ` + help: i18n.translate('xpack.lens.formula.ceilFunction.markdown', { + defaultMessage: ` Ceiling of value, rounds up. Example: Round up price to the next dollar -${'`ceil(sum(price))`'} +\`ceil(sum(price))\` `, + }), }, clamp: { positionalArguments: [ @@ -238,8 +251,8 @@ ${'`ceil(sum(price))`'} type: getTypeI18n('number'), }, ], - // signature: 'clamp(value: number, minimum: number, maximum: number)', - help: ` + help: i18n.translate('xpack.lens.formula.clampFunction.markdown', { + defaultMessage: ` Limits the value from a minimum to maximum. Example: Make sure to catch outliers @@ -251,6 +264,7 @@ clamp( ) \`\`\` `, + }), }, cube: { positionalArguments: [ @@ -259,12 +273,14 @@ clamp( type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.cubeFunction.markdown', { + defaultMessage: ` Calculates the cube of a number. Example: Calculate volume from side length -${'`cube(last_value(length))`'} +\`cube(last_value(length))\` `, + }), }, exp: { positionalArguments: [ @@ -273,13 +289,15 @@ ${'`cube(last_value(length))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.expFunction.markdown', { + defaultMessage: ` Raises *e* to the nth power. Example: Calculate the natural exponential function -${'`exp(last_value(duration))`'} +\`exp(last_value(duration))\` `, + }), }, fix: { positionalArguments: [ @@ -288,12 +306,14 @@ ${'`exp(last_value(duration))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.fixFunction.markdown', { + defaultMessage: ` For positive values, takes the floor. For negative values, takes the ceiling. Example: Rounding towards zero -${'`fix(sum(profit))`'} +\`fix(sum(profit))\` `, + }), }, floor: { positionalArguments: [ @@ -302,12 +322,14 @@ ${'`fix(sum(profit))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.floorFunction.markdown', { + defaultMessage: ` Round down to nearest integer value Example: Round down a price -${'`floor(sum(price))`'} +\`floor(sum(price))\` `, + }), }, log: { positionalArguments: [ @@ -322,7 +344,8 @@ ${'`floor(sum(price))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.logFunction.markdown', { + defaultMessage: ` Logarithm with optional base. The natural base *e* is used as default. Example: Calculate number of bits required to store values @@ -331,17 +354,8 @@ log(sum(bytes)) log(sum(bytes), 2) \`\`\` `, + }), }, - // TODO: check if this is valid for Tinymath - // log10: { - // positionalArguments: [ - // { name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }), type: getTypeI18n('number') }, - // ], - // help: ` - // Base 10 logarithm. - // Example: ${'`log10(sum(bytes))`'} - // `, - // }, mod: { positionalArguments: [ { @@ -353,12 +367,14 @@ log(sum(bytes), 2) type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.modFunction.markdown', { + defaultMessage: ` Remainder after dividing the function by a number Example: Calculate last three digits of a value -${'`mod(sum(price), 1000)`'} +\`mod(sum(price), 1000)\` `, + }), }, pow: { positionalArguments: [ @@ -371,12 +387,14 @@ ${'`mod(sum(price), 1000)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.powFunction.markdown', { + defaultMessage: ` Raises the value to a certain power. The second argument is required Example: Calculate volume based on side length -${'`pow(last_value(length), 3)`'} +\`pow(last_value(length), 3)\` `, + }), }, round: { positionalArguments: [ @@ -391,7 +409,8 @@ ${'`pow(last_value(length), 3)`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.roundFunction.markdown', { + defaultMessage: ` Rounds to a specific number of decimal places, default of 0 Examples: Round to the cent @@ -400,6 +419,7 @@ round(sum(bytes)) round(sum(bytes), 2) \`\`\` `, + }), }, sqrt: { positionalArguments: [ @@ -408,12 +428,14 @@ round(sum(bytes), 2) type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.sqrtFunction.markdown', { + defaultMessage: ` Square root of a positive value only Example: Calculate side length based on area -${'`sqrt(last_value(area))`'} +\`sqrt(last_value(area))\` `, + }), }, square: { positionalArguments: [ @@ -422,12 +444,14 @@ ${'`sqrt(last_value(area))`'} type: getTypeI18n('number'), }, ], - help: ` + help: i18n.translate('xpack.lens.formula.squareFunction.markdown', { + defaultMessage: ` Raise the value to the 2nd power Example: Calculate area based on side length -${'`square(last_value(length))`'} +\`square(last_value(length))\` `, + }), }, }; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/types.ts b/x-pack/plugins/ml/public/application/components/data_grid/types.ts index 9dcd6abb432b33..47684ee307e99f 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/types.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/types.ts @@ -82,6 +82,7 @@ export interface UseIndexDataReturnType | 'resultsField' > { renderCellValue: RenderCellValue; + indexPatternFields?: string[]; } export interface UseDataGridReturnType { 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 669b95cbaeb8cd..bac6b0b9274f52 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 @@ -102,7 +102,7 @@ export enum INDEX_STATUS { export interface FieldSelectionItem { name: string; - mappings_types: string[]; + mappings_types?: string[]; is_included: boolean; is_required: boolean; feature_type?: string; 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 cc01a8c3f9405f..47f7c2621802e5 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 @@ -115,6 +115,7 @@ export const ConfigurationStepForm: FC = ({ const [fetchingExplainData, setFetchingExplainData] = useState(false); const [maxDistinctValuesError, setMaxDistinctValuesError] = useState(); const [unsupportedFieldsError, setUnsupportedFieldsError] = useState(); + const [noDocsContainMappedFields, setNoDocsContainMappedFields] = useState(false); const [minimumFieldsRequiredMessage, setMinimumFieldsRequiredMessage] = useState< undefined | string >(); @@ -261,9 +262,13 @@ export const ConfigurationStepForm: FC = ({ formToUse.includes = [...includes, dependentVariable]; } - const { success, expectedMemory, fieldSelection, errorMessage } = await fetchExplainData( - formToUse - ); + const { + success, + expectedMemory, + fieldSelection, + errorMessage, + noDocsContainMappedFields: noDocsWithFields, + } = await fetchExplainData(formToUse); if (success) { if (shouldUpdateEstimatedMml) { @@ -286,6 +291,7 @@ export const ConfigurationStepForm: FC = ({ setFieldOptionsFetchFail(false); setMaxDistinctValuesError(undefined); setUnsupportedFieldsError(undefined); + setNoDocsContainMappedFields(false); setIncludesTableItems(fieldSelection ? fieldSelection : []); } @@ -315,6 +321,7 @@ export const ConfigurationStepForm: FC = ({ setFieldOptionsFetchFail(true); setMaxDistinctValuesError(maxDistinctValuesErrorMessage); setUnsupportedFieldsError(unsupportedFieldsErrorMessage); + setNoDocsContainMappedFields(noDocsWithFields); setFetchingExplainData(false); setFormState({ ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: fallbackModelMemoryLimit } : {}), @@ -326,6 +333,17 @@ export const ConfigurationStepForm: FC = ({ setFormState({ sourceIndex: currentIndexPattern.title }); }, []); + const indexPatternFieldsTableItems = useMemo(() => { + if (indexData?.indexPatternFields !== undefined) { + return indexData.indexPatternFields.map((field) => ({ + name: field, + is_included: false, + is_required: false, + })); + } + return []; + }, [`${indexData?.indexPatternFields}`]); + useEffect(() => { if (typeof savedSearchQueryStr === 'string') { setFormState({ jobConfigQuery: savedSearchQuery, jobConfigQueryString: savedSearchQueryStr }); @@ -399,7 +417,12 @@ export const ConfigurationStepForm: FC = ({ ? [...updatedIncludes, dependentVariable] : updatedIncludes; - const { success, fieldSelection, errorMessage } = await fetchExplainData(formCopy); + const { + success, + fieldSelection, + errorMessage, + noDocsContainMappedFields: noDocsWithFields, + } = await fetchExplainData(formCopy); if (success) { // update the field selection table const hasRequiredFields = fieldSelection.some( @@ -423,6 +446,7 @@ export const ConfigurationStepForm: FC = ({ setIncludesTableItems(updatedFieldSelection ? updatedFieldSelection : fieldSelection); setMaxDistinctValuesError(undefined); setUnsupportedFieldsError(undefined); + setNoDocsContainMappedFields(noDocsWithFields); setFormState({ includes: updatedIncludes, requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, @@ -444,6 +468,7 @@ export const ConfigurationStepForm: FC = ({ setMaxDistinctValuesError(maxDistinctValuesErrorMessage); setUnsupportedFieldsError(unsupportedFieldsErrorMessage); + setNoDocsContainMappedFields(noDocsWithFields); } } } @@ -501,6 +526,11 @@ export const ConfigurationStepForm: FC = ({ // `undefined` means uninitialized, `null` means initialized but not used. if (savedSearchQuery === undefined) return null; + const tableItems = + includesTableItems.length > 0 && !noDocsContainMappedFields + ? includesTableItems + : indexPatternFieldsTableItems; + return ( @@ -649,7 +679,7 @@ export const ConfigurationStepForm: FC = ({ includes={includes} minimumFieldsRequiredMessage={minimumFieldsRequiredMessage} setMinimumFieldsRequiredMessage={setMinimumFieldsRequiredMessage} - tableItems={includesTableItems} + tableItems={firstUpdate.current ? includesTableItems : tableItems} unsupportedFieldsError={unsupportedFieldsError} setUnsupportedFieldsError={setUnsupportedFieldsError} setFormState={setFormState} 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 ec567f1f96156c..7c83b0af15107a 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 @@ -27,6 +27,7 @@ export const fetchExplainData = async (formState: State['form']) => { let success = true; let expectedMemory = ''; let fieldSelection: FieldSelectionItem[] = []; + let noDocsContainMappedFields = false; try { delete jobConfig.dest; @@ -45,11 +46,19 @@ export const fetchExplainData = async (formState: State['form']) => { } } + if ( + errorMessage.includes('status_exception') && + errorMessage.includes('Unable to estimate memory usage as no documents') + ) { + noDocsContainMappedFields = true; + } + return { success, expectedMemory, fieldSelection, errorMessage, errorReason, + noDocsContainMappedFields, }; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index ddf88ce79ab5b0..b3034d910c7d1e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -255,6 +255,7 @@ export const useIndexData = ( return { ...dataGrid, + indexPatternFields, renderCellValue, }; }; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index d959328218a187..82f8a90fafb7df 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -40,7 +40,6 @@ import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; import { mlEscape } from '../util/string_utils'; import { FormattedTooltip, MlTooltipComponent } from '../components/chart_tooltip/chart_tooltip'; import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; -import { getFormattedSeverityScore } from '../../../common/util/anomaly_utils'; import './_explorer.scss'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; @@ -62,6 +61,9 @@ declare global { } } +function getFormattedSeverityScore(score: number): string { + return String(parseInt(String(score), 10)); +} /** * Ignore insignificant resize, e.g. browser scrollbar appearance. */ @@ -122,7 +124,7 @@ const SwimLaneTooltip = (fieldName?: string): FC<{ values: TooltipValue[] }> => label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', { defaultMessage: 'Max anomaly score', }), - value: cell.formattedValue, + value: cell.formattedValue === '0' ? ' < 1' : cell.formattedValue, color: cell.color, // @ts-ignore seriesIdentifier: { @@ -408,73 +410,75 @@ export const SwimlaneContainer: FC = ({ grow={false} > <> -

- {showSwimlane && !isLoading && ( - - - - - - )} +
+
+ {showSwimlane && !isLoading && ( + + - {isLoading && ( - - + + )} + + {isLoading && ( + + + + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> - - )} - {!isLoading && !showSwimlane && ( - {noDataWarning}} - /> - )} + )} +
{swimlaneType === SWIMLANE_TYPE.OVERALL && showSwimlane && diff --git a/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.test.ts new file mode 100644 index 00000000000000..5c8ef7abbbf514 --- /dev/null +++ b/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.test.ts @@ -0,0 +1,201 @@ +/* + * Copyright 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 { CCRReadExceptionsAlert } from './ccr_read_exceptions_alert'; +import { ALERT_CCR_READ_EXCEPTIONS } from '../../common/constants'; +import { fetchCCRReadExceptions } from '../lib/alerts/fetch_ccr_read_exceptions'; +import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; + +type ICCRReadExceptionsAlertMock = CCRReadExceptionsAlert & { + defaultParams: { + duration: string; + }; +} & { + actionVariables: Array<{ + name: string; + description: string; + }>; +}; + +const RealDate = Date; + +jest.mock('../lib/alerts/fetch_ccr_read_exceptions', () => ({ + fetchCCRReadExceptions: jest.fn(), +})); +jest.mock('../lib/alerts/fetch_clusters', () => ({ + fetchClusters: jest.fn(), +})); + +jest.mock('../static_globals', () => ({ + Globals: { + app: { + getLogger: () => ({ debug: jest.fn() }), + url: 'http://localhost:5601', + config: { + ui: { + ccs: { enabled: true }, + metricbeat: { index: 'metricbeat-*' }, + container: { elasticsearch: { enabled: false } }, + }, + }, + }, + }, +})); + +describe('CCRReadExceptionsAlert', () => { + it('should have defaults', () => { + const alert = new CCRReadExceptionsAlert() as ICCRReadExceptionsAlertMock; + expect(alert.alertOptions.id).toBe(ALERT_CCR_READ_EXCEPTIONS); + expect(alert.alertOptions.name).toBe('CCR read exceptions'); + expect(alert.alertOptions.throttle).toBe('6h'); + expect(alert.alertOptions.defaultParams).toStrictEqual({ + duration: '1h', + }); + expect(alert.alertOptions.actionVariables).toStrictEqual([ + { + name: 'remoteCluster', + description: 'The remote cluster experiencing CCR read exceptions.', + }, + { + name: 'followerIndex', + description: 'The follower index reporting CCR read exceptions.', + }, + { + name: 'internalShortMessage', + description: 'The short internal message generated by Elastic.', + }, + { + name: 'internalFullMessage', + description: 'The full internal message generated by Elastic.', + }, + { name: 'state', description: 'The current state of the alert.' }, + { name: 'clusterName', description: 'The cluster to which the node(s) belongs.' }, + { name: 'action', description: 'The recommended action for this alert.' }, + { + name: 'actionPlain', + description: 'The recommended action for this alert, without any markdown.', + }, + ]); + }); + describe('execute', () => { + const FakeDate = function () {}; + FakeDate.prototype.valueOf = () => 1; + + const clusterUuid = 'abc123'; + const clusterName = 'testCluster'; + const nodeId = 'myNodeId'; + const nodeName = 'myNodeName'; + const remoteCluster = 'BcK-0pmsQniyPQfZuauuXw_remote_cluster_1'; + const followerIndex = '.follower_index_1'; + const leaderIndex = '.leader_index_1'; + const readExceptions = [ + { + exception: { + type: 'read_exceptions_type_1', + reason: 'read_exceptions_reason_1', + }, + }, + ]; + const stat = { + remoteCluster, + followerIndex, + leaderIndex, + read_exceptions: readExceptions, + clusterUuid, + nodeId, + nodeName, + }; + + const replaceState = jest.fn(); + const scheduleActions = jest.fn(); + const getState = jest.fn(); + const executorOptions = { + services: { + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn().mockImplementation(() => { + return { + replaceState, + scheduleActions, + getState, + }; + }), + }, + state: {}, + }; + + beforeEach(() => { + Date = FakeDate as DateConstructor; + (fetchCCRReadExceptions as jest.Mock).mockImplementation(() => { + return [stat]; + }); + (fetchClusters as jest.Mock).mockImplementation(() => { + return [{ clusterUuid, clusterName }]; + }); + }); + + afterEach(() => { + Date = RealDate; + replaceState.mockReset(); + scheduleActions.mockReset(); + getState.mockReset(); + }); + + it('should fire actions', async () => { + const alert = new CCRReadExceptionsAlert() as ICCRReadExceptionsAlertMock; + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + params: alert.alertOptions.defaultParams, + } as any); + expect(scheduleActions).toHaveBeenCalledWith('default', { + internalFullMessage: `CCR read exceptions alert is firing for the following remote cluster: ${remoteCluster}. Current 'follower_index' index affected: ${followerIndex}. [View CCR stats](http://localhost:5601/app/monitoring#/elasticsearch/ccr?_g=(cluster_uuid:${clusterUuid}))`, + internalShortMessage: `CCR read exceptions alert is firing for the following remote cluster: ${remoteCluster}. Verify follower and leader index relationships on the affected remote cluster.`, + action: `[View CCR stats](http://localhost:5601/app/monitoring#/elasticsearch/ccr?_g=(cluster_uuid:${clusterUuid}))`, + actionPlain: + 'Verify follower and leader index relationships on the affected remote cluster.', + clusterName, + state: 'firing', + remoteCluster, + remoteClusters: remoteCluster, + followerIndex, + followerIndices: followerIndex, + }); + }); + + it('should handle ccs', async () => { + const ccs = 'testCluster'; + (fetchCCRReadExceptions as jest.Mock).mockImplementation(() => { + return [ + { + ...stat, + ccs, + }, + ]; + }); + const alert = new CCRReadExceptionsAlert() as ICCRReadExceptionsAlertMock; + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + params: alert.alertOptions.defaultParams, + } as any); + expect(scheduleActions).toHaveBeenCalledWith('default', { + internalFullMessage: `CCR read exceptions alert is firing for the following remote cluster: ${remoteCluster}. Current 'follower_index' index affected: ${followerIndex}. [View CCR stats](http://localhost:5601/app/monitoring#/elasticsearch/ccr?_g=(cluster_uuid:${clusterUuid},ccs:testCluster))`, + internalShortMessage: `CCR read exceptions alert is firing for the following remote cluster: ${remoteCluster}. Verify follower and leader index relationships on the affected remote cluster.`, + action: `[View CCR stats](http://localhost:5601/app/monitoring#/elasticsearch/ccr?_g=(cluster_uuid:${clusterUuid},ccs:testCluster))`, + actionPlain: + 'Verify follower and leader index relationships on the affected remote cluster.', + clusterName, + state: 'firing', + remoteCluster, + remoteClusters: remoteCluster, + followerIndex, + followerIndices: followerIndex, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts b/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts index 2995566c7c096a..28f562b2cb131f 100644 --- a/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/ccr_read_exceptions_alert.ts @@ -47,20 +47,20 @@ export class CCRReadExceptionsAlert extends BaseAlert { }, actionVariables: [ { - name: 'remoteClusters', + name: 'remoteCluster', description: i18n.translate( - 'xpack.monitoring.alerts.ccrReadExceptions.actionVariables.remoteClusters', + 'xpack.monitoring.alerts.ccrReadExceptions.actionVariables.remoteCluster', { - defaultMessage: 'List of remote clusters that are experiencing CCR read exceptions.', + defaultMessage: 'The remote cluster experiencing CCR read exceptions.', } ), }, { - name: 'followerIndices', + name: 'followerIndex', description: i18n.translate( - 'xpack.monitoring.alerts.ccrReadExceptions.actionVariables.followerIndices', + 'xpack.monitoring.alerts.ccrReadExceptions.actionVariables.followerIndex', { - defaultMessage: 'List of follower indices reporting CCR read exceptions.', + defaultMessage: 'The follower index reporting CCR read exceptions.', } ), }, @@ -229,12 +229,11 @@ export class CCRReadExceptionsAlert extends BaseAlert { item: AlertData | null, cluster: AlertCluster ) { - const remoteClustersList = alertStates - .map((alertState) => (alertState.meta as CCRReadExceptionsUIMeta).remoteCluster) - .join(', '); - const followerIndicesList = alertStates - .map((alertState) => (alertState.meta as CCRReadExceptionsUIMeta).followerIndex) - .join(', '); + if (alertStates.length === 0) { + return; + } + const CCRReadExceptionsMeta = alertStates[0].meta as CCRReadExceptionsUIMeta; + const { remoteCluster, followerIndex } = CCRReadExceptionsMeta; const shortActionText = i18n.translate( 'xpack.monitoring.alerts.ccrReadExceptions.shortAction', @@ -258,9 +257,9 @@ export class CCRReadExceptionsAlert extends BaseAlert { const internalShortMessage = i18n.translate( 'xpack.monitoring.alerts.ccrReadExceptions.firing.internalShortMessage', { - defaultMessage: `CCR read exceptions alert is firing for the following remote cluster: {remoteClustersList}. {shortActionText}`, + defaultMessage: `CCR read exceptions alert is firing for the following remote cluster: {remoteCluster}. {shortActionText}`, values: { - remoteClustersList, + remoteCluster, shortActionText, }, } @@ -268,11 +267,11 @@ export class CCRReadExceptionsAlert extends BaseAlert { const internalFullMessage = i18n.translate( 'xpack.monitoring.alerts.ccrReadExceptions.firing.internalFullMessage', { - defaultMessage: `CCR read exceptions alert is firing for the following remote cluster: {remoteClustersList}. Current 'follower_index' index affected: {followerIndicesList}. {action}`, + defaultMessage: `CCR read exceptions alert is firing for the following remote cluster: {remoteCluster}. Current 'follower_index' index affected: {followerIndex}. {action}`, values: { action, - remoteClustersList, - followerIndicesList, + remoteCluster, + followerIndex, }, } ); @@ -281,8 +280,14 @@ export class CCRReadExceptionsAlert extends BaseAlert { internalShortMessage, internalFullMessage, state: AlertingDefaults.ALERT_STATE.firing, - remoteClusters: remoteClustersList, - followerIndices: followerIndicesList, + remoteCluster, + followerIndex, + /* continue to send "remoteClusters" and "followerIndices" values for users still using it though + we have replaced it with "remoteCluster" and "followerIndex" in the template due to alerts per index instead of all indices + see https://github.com/elastic/kibana/issues/100136#issuecomment-865229431 + */ + remoteClusters: remoteCluster, + followerIndices: followerIndex, clusterName: cluster.clusterName, action, actionPlain: shortActionText, diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.test.ts new file mode 100644 index 00000000000000..18987a24e5524f --- /dev/null +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LargeShardSizeAlert } from './large_shard_size_alert'; +import { ALERT_LARGE_SHARD_SIZE } from '../../common/constants'; +import { fetchIndexShardSize } from '../lib/alerts/fetch_index_shard_size'; +import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; + +type ILargeShardSizeAlertMock = LargeShardSizeAlert & { + defaultParams: { + threshold: number; + duration: string; + }; +} & { + actionVariables: Array<{ + name: string; + description: string; + }>; +}; + +const RealDate = Date; + +jest.mock('../lib/alerts/fetch_index_shard_size', () => ({ + fetchIndexShardSize: jest.fn(), +})); +jest.mock('../lib/alerts/fetch_clusters', () => ({ + fetchClusters: jest.fn(), +})); + +jest.mock('../static_globals', () => ({ + Globals: { + app: { + getLogger: () => ({ debug: jest.fn() }), + url: 'http://localhost:5601', + config: { + ui: { + ccs: { enabled: true }, + metricbeat: { index: 'metricbeat-*' }, + container: { elasticsearch: { enabled: false } }, + }, + }, + }, + }, +})); + +describe('LargeShardSizeAlert', () => { + it('should have defaults', () => { + const alert = new LargeShardSizeAlert() as ILargeShardSizeAlertMock; + expect(alert.alertOptions.id).toBe(ALERT_LARGE_SHARD_SIZE); + expect(alert.alertOptions.name).toBe('Shard size'); + expect(alert.alertOptions.throttle).toBe('12h'); + expect(alert.alertOptions.defaultParams).toStrictEqual({ + threshold: 55, + indexPattern: '-.*', + }); + expect(alert.alertOptions.actionVariables).toStrictEqual([ + { name: 'shardIndex', description: 'The index experiencing large average shard size.' }, + { + name: 'internalShortMessage', + description: 'The short internal message generated by Elastic.', + }, + { + name: 'internalFullMessage', + description: 'The full internal message generated by Elastic.', + }, + { name: 'state', description: 'The current state of the alert.' }, + { name: 'clusterName', description: 'The cluster to which the node(s) belongs.' }, + { name: 'action', description: 'The recommended action for this alert.' }, + { + name: 'actionPlain', + description: 'The recommended action for this alert, without any markdown.', + }, + ]); + }); + describe('execute', () => { + const FakeDate = function () {}; + FakeDate.prototype.valueOf = () => 1; + + const shardIndex = 'apm-8.0.0-onboarding-2021.06.30'; + const shardSize = 0; + const clusterUuid = 'abc123'; + const clusterName = 'testCluster'; + const nodeId = 'myNodeId'; + const nodeName = 'myNodeName'; + const stat = { + shardIndex, + shardSize, + clusterUuid, + nodeId, + nodeName, + }; + + const replaceState = jest.fn(); + const scheduleActions = jest.fn(); + const getState = jest.fn(); + const executorOptions = { + services: { + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + alertInstanceFactory: jest.fn().mockImplementation(() => { + return { + replaceState, + scheduleActions, + getState, + }; + }), + }, + state: {}, + }; + + beforeEach(() => { + Date = FakeDate as DateConstructor; + (fetchIndexShardSize as jest.Mock).mockImplementation(() => { + return [stat]; + }); + (fetchClusters as jest.Mock).mockImplementation(() => { + return [{ clusterUuid, clusterName }]; + }); + }); + + afterEach(() => { + Date = RealDate; + replaceState.mockReset(); + scheduleActions.mockReset(); + getState.mockReset(); + }); + + it('should fire actions', async () => { + const alert = new LargeShardSizeAlert() as ILargeShardSizeAlertMock; + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + params: alert.alertOptions.defaultParams, + } as any); + expect(scheduleActions).toHaveBeenCalledWith('default', { + internalFullMessage: `Large shard size alert is firing for the following index: ${shardIndex}. [View index shard size stats](http://localhost:5601/app/monitoring#/elasticsearch/indices/${shardIndex}?_g=(cluster_uuid:${clusterUuid}))`, + internalShortMessage: `Large shard size alert is firing for the following index: ${shardIndex}. Investigate indices with large shard sizes.`, + action: `[View index shard size stats](http://localhost:5601/app/monitoring#/elasticsearch/indices/${shardIndex}?_g=(cluster_uuid:${clusterUuid}))`, + actionPlain: 'Investigate indices with large shard sizes.', + clusterName, + state: 'firing', + shardIndex, + shardIndices: shardIndex, + }); + }); + + it('should handle ccs', async () => { + const ccs = 'testCluster'; + (fetchIndexShardSize as jest.Mock).mockImplementation(() => { + return [ + { + ...stat, + ccs, + }, + ]; + }); + const alert = new LargeShardSizeAlert() as ILargeShardSizeAlertMock; + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + params: alert.alertOptions.defaultParams, + } as any); + expect(scheduleActions).toHaveBeenCalledWith('default', { + internalFullMessage: `Large shard size alert is firing for the following index: ${shardIndex}. [View index shard size stats](http://localhost:5601/app/monitoring#/elasticsearch/indices/${shardIndex}?_g=(cluster_uuid:${clusterUuid},ccs:testCluster))`, + internalShortMessage: `Large shard size alert is firing for the following index: ${shardIndex}. Investigate indices with large shard sizes.`, + action: `[View index shard size stats](http://localhost:5601/app/monitoring#/elasticsearch/indices/${shardIndex}?_g=(cluster_uuid:${clusterUuid},ccs:testCluster))`, + actionPlain: 'Investigate indices with large shard sizes.', + clusterName, + state: 'firing', + shardIndex, + shardIndices: shardIndex, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts index 75e22fb41025c7..a365e530cbd05a 100644 --- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts @@ -211,6 +211,11 @@ export class LargeShardSizeAlert extends BaseAlert { internalShortMessage, internalFullMessage, state: AlertingDefaults.ALERT_STATE.firing, + /* continue to send "shardIndices" values for users still using it though + we have replaced it with shardIndex in the template due to alerts per index instead of all indices + see https://github.com/elastic/kibana/issues/100136#issuecomment-865229431 + */ + shardIndices: shardIndex, shardIndex, clusterName: cluster.clusterName, action, diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index d13140f0be16ce..6bd96e012548d8 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -18,6 +18,7 @@ "data", "features", "ruleRegistry", + "timelines", "triggersActionsUi" ], "ui": true, diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx index c7faa28b046854..53b5300e556c53 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_flyout/index.tsx @@ -31,7 +31,7 @@ import { } from '@kbn/rule-data-utils/target/technical_field_names'; import moment from 'moment-timezone'; import React, { useMemo } from 'react'; -import type { TopAlertResponse } from '../'; +import type { TopAlert, TopAlertResponse } from '../'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; import { asDuration } from '../../../../common/utils/formatters'; import type { ObservabilityRuleTypeRegistry } from '../../../rules/create_observability_rule_type_registry'; @@ -39,6 +39,7 @@ import { decorateResponse } from '../decorate_response'; import { SeverityBadge } from '../severity_badge'; type AlertsFlyoutProps = { + alert?: TopAlert; alerts?: TopAlertResponse[]; isInApp?: boolean; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; @@ -46,6 +47,7 @@ type AlertsFlyoutProps = { } & EuiFlyoutProps; export function AlertsFlyout({ + alert, alerts, isInApp = false, observabilityRuleTypeRegistry, @@ -59,9 +61,12 @@ export function AlertsFlyout({ const decoratedAlerts = useMemo(() => { return decorateResponse(alerts ?? [], observabilityRuleTypeRegistry); }, [alerts, observabilityRuleTypeRegistry]); - const alert = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); - if (!alert) { + let alertData = alert; + if (!alertData) { + alertData = decoratedAlerts?.find((a) => a.fields[ALERT_UUID] === selectedAlertId); + } + if (!alertData) { return null; } @@ -70,45 +75,45 @@ export function AlertsFlyout({ title: i18n.translate('xpack.observability.alertsFlyout.statusLabel', { defaultMessage: 'Status', }), - description: alert.active ? 'Active' : 'Recovered', + description: alertData.active ? 'Active' : 'Recovered', }, { title: i18n.translate('xpack.observability.alertsFlyout.severityLabel', { defaultMessage: 'Severity', }), - description: , + description: , }, { title: i18n.translate('xpack.observability.alertsFlyout.triggeredLabel', { defaultMessage: 'Triggered', }), description: ( - {moment(alert.start).format(dateFormat)} + {moment(alertData.start).format(dateFormat)} ), }, { title: i18n.translate('xpack.observability.alertsFlyout.durationLabel', { defaultMessage: 'Duration', }), - description: asDuration(alert.fields[ALERT_DURATION], { extended: true }), + description: asDuration(alertData.fields[ALERT_DURATION], { extended: true }), }, { title: i18n.translate('xpack.observability.alertsFlyout.expectedValueLabel', { defaultMessage: 'Expected value', }), - description: alert.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', + description: alertData.fields[ALERT_EVALUATION_THRESHOLD] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.actualValueLabel', { defaultMessage: 'Actual value', }), - description: alert.fields[ALERT_EVALUATION_VALUE] ?? '-', + description: alertData.fields[ALERT_EVALUATION_VALUE] ?? '-', }, { title: i18n.translate('xpack.observability.alertsFlyout.ruleTypeLabel', { defaultMessage: 'Rule type', }), - description: alert.fields[RULE_CATEGORY] ?? '-', + description: alertData.fields[RULE_CATEGORY] ?? '-', }, ]; @@ -116,10 +121,10 @@ export function AlertsFlyout({ -

{alert.fields[RULE_NAME]}

+

{alertData.fields[RULE_NAME]}

- {alert.reason} + {alertData.reason}
@@ -129,11 +134,11 @@ export function AlertsFlyout({ listItems={overviewListItems} /> - {alert.link && !isInApp && ( + {alertData.link && !isInApp && ( - + View in app diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx index c0a08fa7faac7a..b2d44f9a598dde 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx @@ -7,17 +7,17 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo, useState } from 'react'; -import { SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; +import { IIndexPattern, SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { useFetcher } from '../../hooks/use_fetcher'; -import { callObservabilityApi } from '../../services/call_observability_api'; export function AlertsSearchBar({ + dynamicIndexPattern, rangeFrom, rangeTo, onQueryChange, query, }: { + dynamicIndexPattern: IIndexPattern[]; rangeFrom?: string; rangeTo?: string; query?: string; @@ -31,16 +31,9 @@ export function AlertsSearchBar({ }, []); const [queryLanguage, setQueryLanguage] = useState<'lucene' | 'kuery'>('kuery'); - const { data: dynamicIndexPattern } = useFetcher(({ signal }) => { - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', - }); - }, []); - return ( void) => void; +} + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.statusColumnDescription', { + defaultMessage: 'Status', + }), + id: ALERT_STATUS, + initialWidth: 79, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.triggeredColumnDescription', { + defaultMessage: 'Triggered', + }), + id: ALERT_START, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.durationColumnDescription', { + defaultMessage: 'Duration', + }), + id: ALERT_DURATION, + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.severityColumnDescription', { + defaultMessage: 'Severity', + }), + id: ALERT_SEVERITY_LEVEL, + initialWidth: 102, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: i18n.translate('xpack.observability.alertsTGrid.reasonColumnDescription', { + defaultMessage: 'Reason', + }), + linkField: '*', + id: RULE_NAME, + initialWidth: 400, + }, +]; + +const NO_ROW_RENDER: RowRenderer[] = []; + +const trailingControlColumns: never[] = []; + +export function AlertsTableTGrid(props: AlertsTableTGridProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const { prepend } = core.http.basePath; + const { indexName, rangeFrom, rangeTo, kuery, status, setRefetch } = props; + const [flyoutAlert, setFlyoutAlert] = useState(undefined); + const handleFlyoutClose = () => setFlyoutAlert(undefined); + const { timelines } = useKibana<{ timelines: TimelinesUIStart }>().services; + + const leadingControlColumns = [ + { + id: 'expand', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + setFlyoutAlert(alert)} + /> + ); + }, + }, + { + id: 'view_in_app', + width: 40, + headerCellRender: () => null, + rowCellRender: ({ data }: ActionProps) => { + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + return ( + + ); + }, + }, + ]; + + return ( + <> + {flyoutAlert && ( + + + + )} + {timelines.getTGrid<'standalone'>({ + type: 'standalone', + columns, + deletedEventIds: [], + end: rangeTo, + filters: [], + indexNames: [indexName], + itemsPerPage: 10, + itemsPerPageOptions: [10, 25, 50], + loadingText: i18n.translate('xpack.observability.alertsTable.loadingTextLabel', { + defaultMessage: 'loading alerts', + }), + footerText: i18n.translate('xpack.observability.alertsTable.footerTextLabel', { + defaultMessage: 'alerts', + }), + query: { + query: `${ALERT_STATUS}: ${status}${kuery !== '' ? ` and ${kuery}` : ''}`, + language: 'kuery', + }, + renderCellValue: getRenderCellValue({ rangeFrom, rangeTo, setFlyoutAlert }), + rowRenderers: NO_ROW_RENDER, + start: rangeFrom, + setRefetch, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + sortDirection: 'desc', + }, + ], + leadingControlColumns, + trailingControlColumns, + unit: (totalAlerts: number) => + i18n.translate('xpack.observability.alertsTable.showingAlertsTitle', { + values: { totalAlerts }, + defaultMessage: '{totalAlerts, plural, =1 {alert} other {alerts}}', + }), + })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.tsx new file mode 100644 index 00000000000000..38919857e86c11 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid_actions.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 { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RULE_ID, RULE_NAME } from '@kbn/rule-data-utils/target/technical_field_names'; +import React, { useState } from 'react'; +import { format, parse } from 'url'; + +import { parseTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; +import type { ActionProps } from '../../../../timelines/common'; +import { asDuration, asPercent } from '../../../common/utils/formatters'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +export function RowCellActionsRender({ data }: ActionProps) { + const { core, observabilityRuleTypeRegistry } = usePluginContext(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { prepend } = core.http.basePath; + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const parsedFields = parseTechnicalFields(dataFieldEs); + const formatter = observabilityRuleTypeRegistry.getFormatter(parsedFields[RULE_ID]!); + const formatted = { + link: undefined, + reason: parsedFields[RULE_NAME]!, + ...(formatter?.({ fields: parsedFields, formatters: { asDuration, asPercent } }) ?? {}), + }; + + const parsedLink = formatted.link ? parse(formatted.link, true) : undefined; + const link = parsedLink + ? format({ + ...parsedLink, + query: { + ...parsedLink.query, + rangeFrom: 'now-24h', + rangeTo: 'now', + }, + }) + : undefined; + return ( +
+ setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + > + Actions +
+ + + + + + + {i18n.translate('xpack.observability.alertsTable.viewInAppButtonLabel', { + defaultMessage: 'View in app', + })} + + + +
+
+
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index 6f696a70665ce6..fed9ee0be3a4a2 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -7,21 +7,20 @@ import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; import { ParsedTechnicalFields } from '../../../../rule_registry/common/parse_technical_fields'; import type { AlertStatus } from '../../../common/typings'; import { ExperimentalBadge } from '../../components/shared/experimental_badge'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; -import { useFetcher } from '../../hooks/use_fetcher'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; -import { callObservabilityApi } from '../../services/call_observability_api'; import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types'; -import { getAbsoluteDateRange } from '../../utils/date'; import { AlertsSearchBar } from './alerts_search_bar'; -import { AlertsTable } from './alerts_table'; +import { AlertsTableTGrid } from './alerts_table_t_grid'; import { StatusFilter } from './status_filter'; +import { useFetcher } from '../../hooks/use_fetcher'; +import { callObservabilityApi } from '../../services/call_observability_api'; export type TopAlertResponse = ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>[number]; @@ -41,6 +40,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { const { core, ObservabilityPageTemplate } = usePluginContext(); const { prepend } = core.http.basePath; const history = useHistory(); + const refetch = useRef<() => void>(); const { query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '', status = 'open' }, } = routeParams; @@ -59,37 +59,52 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { '/app/management/insightsAndAlerting/triggersActions/alerts' ); - const { data: alerts } = useFetcher( - ({ signal }) => { - const { start, end } = getAbsoluteDateRange({ rangeFrom, rangeTo }); + const { data: dynamicIndexPatternResp } = useFetcher(({ signal }) => { + return callObservabilityApi({ + signal, + endpoint: 'GET /api/observability/rules/alerts/dynamic_index_pattern', + }); + }, []); + + const dynamicIndexPattern = useMemo( + () => (dynamicIndexPatternResp ? [dynamicIndexPatternResp] : []), + [dynamicIndexPatternResp] + ); + + const setStatusFilter = useCallback( + (value: AlertStatus) => { + const nextSearchParams = new URLSearchParams(history.location.search); + nextSearchParams.set('status', value); + history.push({ + ...history.location, + search: nextSearchParams.toString(), + }); + }, + [history] + ); - if (!start || !end) { - return; + const onQueryChange = useCallback( + ({ dateRange, query }) => { + if (rangeFrom === dateRange.from && rangeTo === dateRange.to && kuery === (query ?? '')) { + return refetch.current && refetch.current(); } - return callObservabilityApi({ - signal, - endpoint: 'GET /api/observability/rules/alerts/top', - params: { - query: { - start, - end, - kuery, - status, - }, - }, + const nextSearchParams = new URLSearchParams(history.location.search); + + nextSearchParams.set('rangeFrom', dateRange.from); + nextSearchParams.set('rangeTo', dateRange.to); + nextSearchParams.set('kuery', query ?? ''); + + history.push({ + ...history.location, + search: nextSearchParams.toString(), }); }, - [kuery, rangeFrom, rangeTo, status] + [history, rangeFrom, rangeTo, kuery] ); - function setStatusFilter(value: AlertStatus) { - const nextSearchParams = new URLSearchParams(history.location.search); - nextSearchParams.set('status', value); - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - } + const setRefetch = useCallback((ref) => { + refetch.current = ref; + }, []); return ( { - const nextSearchParams = new URLSearchParams(history.location.search); - - nextSearchParams.set('rangeFrom', dateRange.from); - nextSearchParams.set('rangeTo', dateRange.to); - nextSearchParams.set('kuery', query ?? ''); - - history.push({ - ...history.location, - search: nextSearchParams.toString(), - }); - }} + onQueryChange={onQueryChange} /> @@ -162,7 +167,14 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
- + 0 ? dynamicIndexPattern[0].title : ''} + rangeFrom={rangeFrom} + rangeTo={rangeTo} + kuery={kuery} + status={status} + setRefetch={setRefetch} + /> diff --git a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx new file mode 100644 index 00000000000000..1cd86631197c41 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIconTip, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + ALERT_DURATION, + ALERT_SEVERITY_LEVEL, + ALERT_STATUS, + ALERT_START, + RULE_NAME, +} from '@kbn/rule-data-utils/target/technical_field_names'; + +import type { CellValueElementProps, TimelineNonEcsData } from '../../../../timelines/common'; +import { TimestampTooltip } from '../../components/shared/timestamp_tooltip'; +import { asDuration } from '../../../common/utils/formatters'; +import { SeverityBadge } from './severity_badge'; +import { TopAlert } from '.'; +import { decorateResponse } from './decorate_response'; +import { usePluginContext } from '../../hooks/use_plugin_context'; + +const getMappedNonEcsValue = ({ + data, + fieldName, +}: { + data: TimelineNonEcsData[]; + fieldName: string; +}): string[] | undefined => { + const item = data.find((d) => d.field === fieldName); + if (item != null && item.value != null) { + return item.value; + } + return undefined; +}; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const getRenderCellValue = ({ + rangeTo, + rangeFrom, + setFlyoutAlert, +}: { + rangeTo: string; + rangeFrom: string; + setFlyoutAlert: (data: TopAlert) => void; +}) => { + return ({ columnId, data, linkValues }: CellValueElementProps) => { + const { observabilityRuleTypeRegistry } = usePluginContext(); + const value = getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]); + + switch (columnId) { + case ALERT_STATUS: + return value !== 'closed' ? ( + + ) : ( + + ); + case ALERT_START: + return ; + case ALERT_DURATION: + return asDuration(Number(value), { extended: true }); + case ALERT_SEVERITY_LEVEL: + return ; + case RULE_NAME: + const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); + const decoratedAlerts = decorateResponse( + [dataFieldEs] ?? [], + observabilityRuleTypeRegistry + ); + const alert = decoratedAlerts[0]; + + return ( + setFlyoutAlert && setFlyoutAlert(alert)}>{alert.reason} + ); + default: + return <>{value}; + } + }; +}; diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index b6ed0a0a3d17f6..8aa184bca913f0 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -27,6 +27,7 @@ { "path": "../cases/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../rule_registry/tsconfig.json" }, + { "path": "../timelines/tsconfig.json"}, { "path": "../translations/tsconfig.json" } ] } diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 6efaf42a5ad14e..193f3f2a971ea8 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -65,6 +65,7 @@ export interface ReportSource { objectType: string; title: string; layout?: LayoutParams; + isDeprecated?: boolean; }; meta: { objectType: string; layout?: string }; browser_type: string; @@ -128,6 +129,7 @@ export interface ReportApiJSON { layout?: LayoutParams; title: string; browserTimezone?: string; + isDeprecated?: boolean; }; meta: { layout?: string; diff --git a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts index 876d190c9eee84..56d6facea9212e 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts @@ -20,6 +20,9 @@ export const createJobFnFactory: CreateJobFnFactory< const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob(jobParams, context, request) { + logger.warn( + `The "/generate/csv" endpoint is deprecated and will be removed in Kibana 8.0. Please recreate the POST URL used to automate this CSV export.` + ); const serializedEncryptedHeaders = await crypto.encrypt(request.headers); const savedObjectsClient = context.core.savedObjects.client; @@ -29,6 +32,7 @@ export const createJobFnFactory: CreateJobFnFactory< )) as unknown) as IndexPatternSavedObjectDeprecatedCSV; return { + isDeprecated: true, headers: serializedEncryptedHeaders, spaceId: reporting.getSpaceId(request, logger), indexPatternSavedObject, diff --git a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts index 1db62f818216a7..2fec34470ff1f1 100644 --- a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { UnwrapPromise } from '@kbn/utility-types'; -import { i18n } from '@kbn/i18n'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { i18n } from '@kbn/i18n'; +import { UnwrapPromise } from '@kbn/utility-types'; import { ElasticsearchClient } from 'src/core/server'; import { ReportingCore } from '../../'; import { ReportDocument } from '../../lib/store'; @@ -87,6 +87,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { elasticsearchClient.search({ body, index: getIndex() }) ); + // FIXME: return the info in ReportApiJSON format; return response?.body.hits?.hits ?? []; }, @@ -139,6 +140,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { return; } + // FIXME: return the info in ReportApiJSON format; return response.body.hits.hits[0] as ReportDocument; }, diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 7df1dce597d56b..da228b09f79d26 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -62,6 +62,7 @@ export { BaseParams }; export interface BasePayload extends BaseParams { headers: string; spaceId?: string; + isDeprecated?: boolean; } // default fn type for CreateJobFnFactory diff --git a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts index 039424d34bfa1d..fe3504c84115b4 100644 --- a/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts +++ b/x-pack/plugins/rule_registry/common/field_map/runtime_type_from_fieldmap.ts @@ -6,22 +6,56 @@ */ import { Optional } from 'utility-types'; import { mapValues, pickBy } from 'lodash'; +import { either } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; import { FieldMap } from './types'; +const NumberFromString = new t.Type( + 'NumberFromString', + (u): u is number => typeof u === 'number', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + const d = Number(s); + return isNaN(d) ? t.failure(u, c) : t.success(d); + }), + (a) => a +); + +const BooleanFromString = new t.Type( + 'BooleanFromString', + (u): u is boolean => typeof u === 'boolean', + (u, c) => + either.chain(t.string.validate(u, c), (s) => { + switch (s.toLowerCase().trim()) { + case '1': + case 'true': + case 'yes': + return t.success(true); + case '0': + case 'false': + case 'no': + case null: + return t.success(false); + default: + return t.failure(u, c); + } + }), + (a) => a +); + const esFieldTypeMap = { keyword: t.string, text: t.string, date: t.string, - boolean: t.boolean, - byte: t.number, - long: t.number, - integer: t.number, - short: t.number, - double: t.number, - float: t.number, - scaled_float: t.number, - unsigned_long: t.number, + boolean: t.union([t.number, BooleanFromString]), + byte: t.union([t.number, NumberFromString]), + long: t.union([t.number, NumberFromString]), + integer: t.union([t.number, NumberFromString]), + short: t.union([t.number, NumberFromString]), + double: t.union([t.number, NumberFromString]), + float: t.union([t.number, NumberFromString]), + scaled_float: t.union([t.number, NumberFromString]), + unsigned_long: t.union([t.number, NumberFromString]), flattened: t.record(t.string, t.array(t.string)), }; diff --git a/x-pack/plugins/security_solution/cypress/README.md b/x-pack/plugins/security_solution/cypress/README.md index 0713716a15d510..1b486ca3a5fcdf 100644 --- a/x-pack/plugins/security_solution/cypress/README.md +++ b/x-pack/plugins/security_solution/cypress/README.md @@ -115,8 +115,42 @@ cd x-pack/plugins/security_solution CYPRESS_BASE_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_URL=http(s)://:@ CYPRESS_ELASTICSEARCH_USERNAME= CYPRESS_ELASTICSEARCH_PASSWORD=password yarn cypress:run:firefox ``` +#### CCS Custom Target + Headless + +This test execution requires two clusters configured for CCS. See [Search across clusters](https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-cross-cluster-search.html) for instructions on how to prepare such setup. + +The instructions below assume: +* Search cluster is on server1 +* Remote cluster is on server2 +* Remote cluster is accessible from the search cluster with name `remote` +* Security and TLS are enabled + +```shell +# bootstrap Kibana from the project root +yarn kbn bootstrap + +# launch the Cypress test runner with overridden environment variables +cd x-pack/plugins/security_solution +CYPRESS_ELASTICSEARCH_USERNAME="user" \ +CYPRESS_ELASTICSEARCH_PASSWORD="pass" \ +CYPRESS_BASE_URL="https://user:pass@server1:5601" \ +CYPRESS_ELASTICSEARCH_URL="https://user:pass@server1:9200" \ +CYPRESS_CCS_KIBANA_URL="https://user:pass@server2:5601" \ +CYPRESS_CCS_ELASTICSEARCH_URL="https://user:pass@server2:9200" \ +CYPRESS_CCS_REMOTE_NAME="remote" \ +yarn cypress:run:ccs +``` + +Similar sequence, just ending with `yarn cypress:open:ccs`, can be used for interactive test running via Cypress UI. + +Appending `--browser firefox` to the `yarn cypress:run:ccs` command above will run the tests on Firefox instead of Chrome. + ## Folder Structure +### ccs_integration/ + +Contains the specs that are executed in a Cross Cluster Search configuration, typically during integration tests. + ### integration/ Cypress convention. Contains the specs that are going to be executed. @@ -208,6 +242,44 @@ Because of `cy.exec`, used to invoke `es_archiver`, it's necessary to override i > Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification. +### CCS + +Tests running in CCS configuration need to care about two aspects: + +1. data (eg. to trigger alerts) is generated/loaded on the remote cluster +2. queries (eg. detection rules) refer to remote indices + +Incorrect handling of the above points might result in false positives, in that the remote cluster is not involved but the test passes anyway. + +#### Remote data loading + +Helpers `esArchiverCCSLoad` and `esArchiverCCSUnload` are provided by [cypress/tasks/es_archiver.ts](https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts): + +```javascript +import { esArchiverCCSLoad, esArchiverCCSUnload } from '../../tasks/es_archiver'; +``` + +They will use the `CYPRESS_CCS_*_URL` environment variables for accessing the remote cluster. Complex tests involving local and remote data can interleave them with `esArchiverLoad` and `esArchiverUnload` as needed. + +#### Remote indices queries + +Queries accessing remote indices follow the usual `:` notation but should not hard-code the remote name in the test itself. + +For such reason the environemnt variable `CYPRESS_CCS_REMOTE_NAME` is defined and, in the case of detection rules, used as shown below: + +```javascript +const ccsRemoteName: string = Cypress.env('CCS_REMOTE_NAME'); + +export const unmappedCCSRule: CustomRule = { + customQuery: '*:*', + index: [`${ccsRemoteName}:unmapped*`], + ... +}; + +``` + +Similar approach should be used in defining all index patterns, rules, and queries to be applied on remote data. + ## Development Best Practices ### Clean up the state diff --git a/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts new file mode 100644 index 00000000000000..f87399a6669048 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_alerts/alerts_details.spec.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CELL_TEXT, JSON_LINES, TABLE_ROWS } from '../../screens/alerts_details'; + +import { + expandFirstAlert, + waitForAlertsIndexToBeCreated, + waitForAlertsPanelToBeLoaded, +} from '../../tasks/alerts'; +import { openJsonView, openTable, scrollJsonViewToBottom } from '../../tasks/alerts_details'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { esArchiverCCSLoad, esArchiverCCSUnload } from '../../tasks/es_archiver'; + +import { unmappedCCSRule } from '../../objects/rule'; + +import { ALERTS_URL } from '../../urls/navigation'; + +describe('Alert details with unmapped fields', () => { + beforeEach(() => { + cleanKibana(); + esArchiverCCSLoad('unmapped_fields'); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(unmappedCCSRule); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + waitForAlertsPanelToBeLoaded(); + expandFirstAlert(); + }); + + afterEach(() => { + esArchiverCCSUnload('unmapped_fields'); + }); + + it('Displays the unmapped field on the JSON view', () => { + const expectedUnmappedField = { line: 2, text: ' "unmapped": "This is the unmapped field"' }; + + openJsonView(); + scrollJsonViewToBottom(); + + cy.get(JSON_LINES).then((elements) => { + const length = elements.length; + cy.wrap(elements) + .eq(length - expectedUnmappedField.line) + .should('have.text', expectedUnmappedField.text); + }); + }); + + it('Displays the unmapped field on the table', () => { + const expectedUnmmappedField = { + row: 55, + field: 'unmapped', + text: 'This is the unmapped field', + }; + + openTable(); + + cy.get(TABLE_ROWS) + .eq(expectedUnmmappedField.row) + .within(() => { + cy.get(CELL_TEXT).eq(0).should('have.text', expectedUnmmappedField.field); + cy.get(CELL_TEXT).eq(1).should('have.text', expectedUnmmappedField.text); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.ts new file mode 100644 index 00000000000000..fdc4bce677f745 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/links.spec.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 { newRule } from '../../objects/rule'; +import { RULES_MONIROTING_TABLE, RULE_NAME } from '../../screens/alerts_detection_rules'; +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana, reload } from '../../tasks/common'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { ALERTS_URL } from '../../urls/navigation'; + +describe('Rules talbes links', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(ALERTS_URL); + goToManageAlertsDetectionRules(); + waitForAlertsIndexToBeCreated(); + createCustomRuleActivated(newRule, 'rule1'); + + reload(); + }); + + it('should render correct link for rule name - rules', () => { + cy.get(RULE_NAME).first().click(); + cy.url().should('contain', 'rules/id/'); + }); + + it('should render correct link for rule name - rule monitoring', () => { + cy.get(RULES_MONIROTING_TABLE).first().click(); + cy.get(RULE_NAME).first().click(); + cy.url().should('contain', 'rules/id/'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts index c3e04aaaf6a1f9..9986d9d2afbd9a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/row_renderers.spec.ts @@ -63,8 +63,9 @@ describe('Row renderers', () => { cy.get(TIMELINE_ROW_RENDERERS_SEARCHBOX).should('exist'); cy.get(TIMELINE_ROW_RENDERERS_SEARCHBOX).type('flow'); - cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().uncheck(); + // Intercepts should be before click handlers that activate them rather than afterwards or you have race conditions cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).first().uncheck(); cy.wait('@updateTimeline').then((interception) => { expect(interception.request.body.timeline.excludedRowRendererIds).to.contain('netflow'); @@ -84,6 +85,9 @@ describe('Row renderers', () => { cy.get(TIMELINE_ROW_RENDERERS_DISABLE_ALL_BTN).should('exist'); cy.get(TIMELINE_ROW_RENDERERS_MODAL_ITEMS_CHECKBOX).should('be.checked'); + // Intercepts should be before click handlers that activate them rather than afterwards or you have race conditions + cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); + // Keep clicking on the disable all button until the first element of all the elements are no longer checked. // In cases where the click handler is not present on the page just yet, this will cause the button to be clicked // multiple times until it sees that the click took effect. You could go through the whole list but I just check @@ -95,7 +99,6 @@ describe('Row renderers', () => { }) .should('not.be.checked'); - cy.intercept('PATCH', '/api/timeline').as('updateTimeline'); cy.wait('@updateTimeline').its('response.statusCode').should('eq', 200); cy.wait('@updateTimeline').then((interception) => { diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 9a8626f2a0d7df..3383ef4996eadb 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -16,6 +16,8 @@ export const totalNumberOfPrebuiltRulesInEsArchive = 127; export const totalNumberOfPrebuiltRulesInEsArchiveCustomRule = 145; +const ccsRemoteName: string = Cypress.env('CCS_REMOTE_NAME'); + interface MitreAttackTechnique { name: string; subtechniques: string[]; @@ -198,6 +200,24 @@ export const unmappedRule: CustomRule = { maxSignals: 100, }; +export const unmappedCCSRule: CustomRule = { + customQuery: '*:*', + index: [`${ccsRemoteName}:unmapped*`], + name: 'Rule with unmapped fields', + description: 'The new rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['http://example.com/', 'https://example.com/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [mitre1, mitre2], + note: '# test markdown', + runsEvery, + lookBack, + timeline, + maxSignals: 100, +}; + export const existingRule: CustomRule = { customQuery: 'host.name: *', name: 'Rule 1', diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index ba071184d98eba..0bf0e5a09e328b 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -72,6 +72,8 @@ export const RULES_TABLE = '[data-test-subj="rules-table"]'; export const RULES_ROW = '.euiTableRow'; +export const RULES_MONIROTING_TABLE = '[data-test-subj="allRulesTableTab-monitoring"]'; + export const SEVENTH_RULE = 6; export const SEVERITY = '[data-test-subj="severity"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts index 94ac8003c0d8bd..83ec1536baf0f5 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts @@ -11,6 +11,8 @@ const ES_ARCHIVE_DIR = '../../test/security_solution_cypress/es_archives'; const CONFIG_PATH = '../../test/functional/config.js'; const ES_URL = Cypress.env('ELASTICSEARCH_URL'); const KIBANA_URL = Cypress.config().baseUrl; +const CCS_ES_URL = Cypress.env('CCS_ELASTICSEARCH_URL'); +const CCS_KIBANA_URL = Cypress.env('CCS_KIBANA_URL'); // Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https const NODE_TLS_REJECT_UNAUTHORIZED = '1'; @@ -37,3 +39,19 @@ export const esArchiverResetKibana = () => { { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false } ); }; + +export const esArchiverCCSLoad = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + cy.exec( + `node ../../../scripts/es_archiver load "${path}" --config "${CONFIG_PATH}" --es-url "${CCS_ES_URL}" --kibana-url "${CCS_KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + ); +}; + +export const esArchiverCCSUnload = (folder: string) => { + const path = Path.join(ES_ARCHIVE_DIR, folder); + cy.exec( + `node ../../../scripts/es_archiver unload "${path}" --config "${CONFIG_PATH}" --es-url "${CCS_ES_URL}" --kibana-url "${CCS_KIBANA_URL}"`, + { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + ); +}; diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 104c6120ecb39b..5362454d3b46ba 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -9,10 +9,12 @@ "build-beat-doc": "node scripts/beat_docs/build.js && node ../../../scripts/eslint ./server/utils/beat_schema/fields.ts --fix", "cypress": "../../../node_modules/.bin/cypress", "cypress:open": "yarn cypress open --config-file ./cypress/cypress.json", + "cypress:open:ccs": "yarn cypress:open --config integrationFolder=./cypress/ccs_integration", "cypress:open-as-ci": "node ../../../scripts/functional_tests --config ../../test/security_solution_cypress/visual_config.ts", "cypress:run": "yarn cypress:run:reporter --browser chrome --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:firefox": "yarn cypress:run:reporter --browser firefox --headless --spec './cypress/integration/**/*.spec.ts'; status=$?; yarn junit:merge && exit $status", "cypress:run:reporter": "yarn cypress run --config-file ./cypress/cypress.json --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=./cypress/reporter_config.json", + "cypress:run:ccs": "yarn cypress:run:reporter --browser chrome --headless --config integrationFolder=./cypress/ccs_integration", "cypress:run-as-ci": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/cli_config.ts", "cypress:run-as-ci:firefox": "node --max-old-space-size=2048 ../../../scripts/functional_tests --config ../../test/security_solution_cypress/config.firefox.ts", "junit:merge": "../../../node_modules/.bin/mochawesome-merge ../../../target/kibana-security-solution/cypress/results/mochawesome*.json > ../../../target/kibana-security-solution/cypress/results/output.json && ../../../node_modules/.bin/marge ../../../target/kibana-security-solution/cypress/results/output.json --reportDir ../../../target/kibana-security-solution/cypress/results && mkdir -p ../../../target/junit && cp ../../../target/kibana-security-solution/cypress/results/*.xml ../../../target/junit/", diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx index a5e0c90402df42..ebd25eef87cb7f 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/route_capture.tsx @@ -9,8 +9,6 @@ import React, { memo, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { AppLocation } from '../../../../common/endpoint/types'; -import { timelineActions } from '../../../timelines/store/timeline'; -import { TimelineId } from '../../../../../timelines/common'; /** * This component should be used above all routes, but below the Provider. @@ -20,10 +18,6 @@ export const RouteCapture = memo(({ children }) => { const location: AppLocation = useLocation(); const dispatch = useDispatch(); - useEffect(() => { - dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); - }, [dispatch, location.pathname]); - useEffect(() => { dispatch({ type: 'userChangedUrl', payload: location }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 90a4e67d76b99d..ccba97f6a7942a 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -179,7 +179,7 @@ describe('EventsViewer', () => { mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); }); - test('it renders the "Showing..." subtitle with the expected event count', () => { + test('it renders the "Showing..." subtitle with the expected event count by default', () => { const wrapper = mount( @@ -190,6 +190,19 @@ describe('EventsViewer', () => { ); }); + test('should not render the "Showing..." subtitle with the expected event count if showTotalCount is set to false ', () => { + const disableSubTitle = { + ...eventsViewerDefaultProps, + showTotalCount: false, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().text()).toEqual(''); + }); + test('it renders the Fields Browser as a settings gear', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index c2f170c58043d0..b8b6b9766bdde3 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -135,6 +135,7 @@ interface Props { rowRenderers: RowRenderer[]; start: string; sort: Sort[]; + showTotalCount?: boolean; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; @@ -163,6 +164,7 @@ const EventsViewerComponent: React.FC = ({ rowRenderers, start, sort, + showTotalCount = true, utilityBar, graphEventId, }) => { @@ -253,8 +255,12 @@ const EventsViewerComponent: React.FC = ({ const subtitle = useMemo( () => - `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit(totalCountMinusDeleted)}`, - [totalCountMinusDeleted, unit] + showTotalCount + ? `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit( + totalCountMinusDeleted + )}` + : null, + [showTotalCount, totalCountMinusDeleted, unit] ); const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 32aa716d4bce3e..bfc14a0f0c6803 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -10,6 +10,7 @@ import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; +import { isEmpty } from 'lodash/fp'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; @@ -40,6 +41,7 @@ export interface OwnProps { id: TimelineId; scopeId: SourcererScopeName; start: string; + showTotalCount?: boolean; headerFilterGroup?: React.ReactNode; pageFilters?: Filter[]; onRuleChange?: () => void; @@ -176,6 +178,7 @@ const StatefulEventsViewerComponent: React.FC = ({ rowRenderers={rowRenderers} start={start} sort={sort} + showTotalCount={isEmpty(graphEventId) ? true : false} utilityBar={utilityBar} graphEventId={graphEventId} /> diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx index b40799895e8a2c..18b99adca3a552 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index.test.tsx @@ -59,6 +59,14 @@ jest.mock('../../lib/kibana', () => ({ }, })); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('UrlStateContainer', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx index e178aba188d11a..3175656f120710 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/index_mocked.test.tsx @@ -31,6 +31,14 @@ jest.mock('../../lib/kibana', () => ({ }), })); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('UrlStateContainer - lodash.throttle mocked to test update url', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx index 487463dfd9d7dc..87e17ba7691cc5 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx @@ -9,6 +9,7 @@ import { difference, isEmpty } from 'lodash/fp'; import { useEffect, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; import { useKibana } from '../../lib/kibana'; import { CONSTANTS, UrlStateType } from './constants'; import { @@ -31,6 +32,8 @@ import { UrlState, } from './types'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; +import { timelineActions } from '../../../timelines/store/timeline'; +import { TimelineId } from '../../../../../timelines/common'; function usePrevious(value: PreviousLocationUrlState) { const ref = useRef(value); @@ -71,6 +74,7 @@ export const useUrlStateHooks = ({ const [isInitializing, setIsInitializing] = useState(true); const { filterManager, savedQueries } = useKibana().services.data.query; const prevProps = usePrevious({ pathName, pageName, urlState }); + const dispatch = useDispatch(); const handleInitialize = (type: UrlStateType, needUpdate?: boolean) => { let mySearch = search; @@ -222,9 +226,10 @@ export const useUrlStateHooks = ({ }); } else if (pathName !== prevProps.pathName) { handleInitialize(type, isDetectionsPages(pageName)); + dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isInitializing, history, pathName, pageName, prevProps, urlState]); + }, [isInitializing, history, pathName, pageName, prevProps, urlState, dispatch]); useEffect(() => { document.title = `${getTitle(pageName, detailName, navTabs)} - Kibana`; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 8d0492267258fd..c6145a70ec8d27 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -313,7 +313,7 @@ export const getColumns = ({ }; export const getMonitoringColumns = ( - history: H.History, + navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise, formatUrl: FormatUrl ): RulesStatusesColumns[] => { const cols: RulesStatusesColumns[] = [ @@ -326,7 +326,10 @@ export const getMonitoringColumns = ( data-test-subj="ruleName" onClick={(ev: { preventDefault: () => void }) => { ev.preventDefault(); - history.push(getRuleDetailsUrl(item.id)); + navigateToApp(APP_ID, { + deepLinkId: SecurityPageName.rules, + path: getRuleDetailsUrl(item.id), + }); }} href={formatUrl(getRuleDetailsUrl(item.id))} > diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 77ca5be0c0ac16..22281fa2c86874 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -300,8 +300,8 @@ export const RulesTables = React.memo( reFetchRules, ]); - const monitoringColumns = useMemo(() => getMonitoringColumns(history, formatUrl), [ - history, + const monitoringColumns = useMemo(() => getMonitoringColumns(navigateToApp, formatUrl), [ + navigateToApp, formatUrl, ]); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index 973dbc41925da0..86bd8b5f47b0bd 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -65,6 +65,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); + useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx index d448b7644cc240..9ad2549c85642c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx @@ -29,14 +29,14 @@ export const EventFiltersListEmptyState = memo<{

} body={ } actions={ @@ -48,7 +48,7 @@ export const EventFiltersListEmptyState = memo<{ > } diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx index c45741c1520b14..9f81d255205248 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -94,12 +94,12 @@ export const EventFiltersFlyout: React.FC = memo( {id ? ( ) : ( )} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 1f3b721fd51e3b..2d608bdc6e1575 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -211,7 +211,7 @@ export const EventFiltersListPage = memo(() => { > ) diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx index 1f520a18470536..96a59383b1a4eb 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx @@ -21,6 +21,7 @@ import { EuiIconTip, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiRangeProps, } from '@elastic/eui'; import { useSelector, useDispatch } from 'react-redux'; import { SideEffectContext } from './side_effect_context'; @@ -32,6 +33,13 @@ import { StyledDescriptionList } from './panels/styles'; import { CubeForProcess } from './panels/cube_for_process'; import { GeneratedText } from './generated_text'; +// EuiRange is currently only horizontally positioned. This reorients the track to a vertical position +const StyledEuiRange = styled(EuiRange)` + & .euiRangeTrack:after { + left: -65px; + transform: rotate(90deg); + } +`; interface StyledGraphControlProps { $backgroundColor: string; $iconColor: string; @@ -275,7 +283,7 @@ export const GraphControls = React.memo( > - = ({ timelineId }) => { [timelineType] ); - const content = useMemo(() => (title.length ? title : placeholder), [title, placeholder]); + const content = useMemo(() => title || placeholder, [title, placeholder]); return ( @@ -239,10 +239,8 @@ const TimelineDescriptionComponent: React.FC = ({ timelineId ); return ( - {description.length ? ( - - {description} - + {description ? ( + {description} ) : ( commonI18n.DESCRIPTION )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index ac6f6e52db1e22..b71cbb4c082eff 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -130,7 +130,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -164,7 +164,13 @@ export const EventsCountComponent = ({ > - + + {totalCount} {footerText} + + } + > {totalCount} diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index b242c0ec2a4a7c..8bb4e6cb45853f 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -26,13 +26,15 @@ type TGridComponent = TGridProps & { store?: Store; storage: Storage; data?: DataPublicPluginStart; + setStore: (store: Store) => void; }; export const TGrid = (props: TGridComponent) => { - const { store, storage, ...tGridProps } = props; + const { store, storage, setStore, ...tGridProps } = props; let tGridStore = store; if (!tGridStore && props.type === 'standalone') { tGridStore = createStore(initialTGridState, storage); + setStore(tGridStore); } let browserFields = EMPTY_BROWSER_FIELDS; if ((tGridProps as TGridIntegratedProps).browserFields != null) { diff --git a/x-pack/plugins/timelines/public/components/loading/index.tsx b/x-pack/plugins/timelines/public/components/loading/index.tsx index 59cc18767af215..652cb6a5dae338 100644 --- a/x-pack/plugins/timelines/public/components/loading/index.tsx +++ b/x-pack/plugins/timelines/public/components/loading/index.tsx @@ -17,7 +17,7 @@ SpinnerFlexItem.displayName = 'SpinnerFlexItem'; export interface LoadingPanelProps { dataTestSubj?: string; - text: string; + text: string | React.ReactNode; height: number | string; showBorder?: boolean; width: number | string; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts index fc566da8c58a2a..6c793e132b7e30 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/helpers.ts @@ -23,17 +23,18 @@ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], browserFields: BrowserFields ): ColumnHeaderOptions[] => { - return headers.map((header) => { - const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] - - return { - ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), - }; - }); + return headers + ? headers.map((header) => { + const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + return { + ...header, + ...get( + [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], + browserFields + ), + }; + }) + : []; }; export const getColumnWidthFromType = (type: string): number => diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx index 23e94b92eaf3d6..c164d0026fdf8e 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/index.tsx @@ -135,7 +135,7 @@ const TgridActionTdCell = ({ rowIndex, hasRowRenderers, onRuleChange, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, showNotes = false, tabType, @@ -267,7 +267,7 @@ export const DataDrivenColumns = React.memo( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx index dca3b84eb84b7f..2db1bde08bd0c4 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/events/event_column_view.tsx @@ -58,7 +58,7 @@ export const EventColumnView = React.memo( hasRowRenderers, onRuleChange, renderCellValue, - selectedEventIds, + selectedEventIds = {}, showCheckboxes, tabType, timelineId, @@ -82,7 +82,6 @@ export const EventColumnView = React.memo( .join(' '), [columnHeaders, data] ); - const leadingActionCells = useMemo( () => leadingControlColumns ? leadingControlColumns.map((column) => column.rowCellRender) : [], diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx index 2978759b6d148f..b7fb0b40c03450 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/index.tsx @@ -110,7 +110,7 @@ export const EventsCountComponent = ({ itemsCount: number; onClick: () => void; serverSideEventCount: number; - footerText: string; + footerText: string | React.ReactNode; }) => { const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ serverSideEventCount, @@ -144,7 +144,7 @@ export const EventsCountComponent = ({ > - + {totalCount} @@ -305,7 +305,7 @@ export const FooterComponent = ({ data-test-subj="LoadingPanelTimeline" height="35px" showBorder={false} - text={`${loadingText}...`} + text={loadingText} width="100%" /> diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 75aae2ed55c4b6..c267a0e57dd2ca 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -40,6 +40,7 @@ import { StatefulBody } from '../body'; import { Footer, footerHeight } from '../footer'; import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../styles'; import * as i18n from './translations'; +import { InspectButtonContainer } from '../../inspect'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px const UTILITY_BAR_HEIGHT = 19; // px @@ -103,7 +104,9 @@ export interface TGridStandaloneProps { columns: ColumnHeaderOptions[]; deletedEventIds: Readonly; end: string; + loadingText: React.ReactNode; filters: Filter[]; + footerText: React.ReactNode; headerFilterGroup?: React.ReactNode; height?: number; indexNames: string[]; @@ -113,6 +116,7 @@ export interface TGridStandaloneProps { onRuleChange?: () => void; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; + setRefetch: (ref: () => void) => void; start: string; sort: SortColumnTimeline[]; utilityBar?: (refetch: Refetch, totalCount: number) => React.ReactNode; @@ -120,13 +124,17 @@ export interface TGridStandaloneProps { leadingControlColumns: ControlColumnProps[]; trailingControlColumns: ControlColumnProps[]; data?: DataPublicPluginStart; + unit: (total: number) => React.ReactNode; } +const basicUnit = (n: number) => i18n.UNIT(n); const TGridStandaloneComponent: React.FC = ({ columns, deletedEventIds, end, + loadingText, filters, + footerText, headerFilterGroup, indexNames, itemsPerPage, @@ -135,6 +143,7 @@ const TGridStandaloneComponent: React.FC = ({ query, renderCellValue, rowRenderers, + setRefetch, start, sort, utilityBar, @@ -142,6 +151,7 @@ const TGridStandaloneComponent: React.FC = ({ leadingControlColumns, trailingControlColumns, data, + unit = basicUnit, }) => { const dispatch = useDispatch(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; @@ -155,7 +165,6 @@ const TGridStandaloneComponent: React.FC = ({ queryFields, title, } = useDeepEqualSelector((state) => getTGrid(state, STANDALONE_ID ?? '')); - const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); useEffect(() => { dispatch(tGridActions.updateIsLoading({ id: STANDALONE_ID, isLoading: isQueryLoading })); }, [dispatch, isQueryLoading]); @@ -216,6 +225,7 @@ const TGridStandaloneComponent: React.FC = ({ skip: !canQueryTimeline, data, }); + setRefetch(refetch); const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), @@ -268,71 +278,81 @@ const TGridStandaloneComponent: React.FC = ({ showCheckboxes: false, }) ); + dispatch( + tGridActions.initializeTGridSettings({ + footerText, + id: STANDALONE_ID, + loadingText, + unit, + }) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - - {canQueryTimeline ? ( - <> - - {HeaderSectionContent} - - {utilityBar && !resolverIsShowing(graphEventId) && ( - {utilityBar?.(refetch, totalCountMinusDeleted)} - )} - - - - -