diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1cf0300b9e17..4db1c2dd3b5eb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -263,10 +263,8 @@ /x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security /x-pack/test/functional/apps/security/ @elastic/kibana-security /x-pack/test/kerberos_api_integration/ @elastic/kibana-security -/x-pack/test/login_selector_api_integration/ @elastic/kibana-security /x-pack/test/oidc_api_integration/ @elastic/kibana-security /x-pack/test/pki_api_integration/ @elastic/kibana-security -/x-pack/test/saml_api_integration/ @elastic/kibana-security /x-pack/test/security_api_integration/ @elastic/kibana-security /x-pack/test/security_functional/ @elastic/kibana-security /x-pack/test/spaces_api_integration/ @elastic/kibana-security diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 534b1cea6242f..c366819c49dde 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,8 +9,9 @@ Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios -- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist) -- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server) +- [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) +- [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) +- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 0bc77ab0a417e..6fd30690b988e 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -14,7 +14,7 @@ For additional *Vega* and *Vega-Lite* information, refer to the reference sectio * Automatic sizing * Default theme to match {kib} * Writing {es} queries using the time range and filters from dashboards -* Using the Elastic Map Service in Vega maps +* experimental[] Using the Elastic Map Service in Vega maps * Additional tooltip styling * Advanced setting to enable URL loading from any domain * Limited debugging support using the browser dev tools diff --git a/package.json b/package.json index cab11047d0cef..2249578207989 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "dependencies": { "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "7.9.1", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", "@elastic/request-crypto": "1.1.4", diff --git a/packages/kbn-ui-framework/package.json b/packages/kbn-ui-framework/package.json index 639d4e17d0e71..21d25311420ca 100644 --- a/packages/kbn-ui-framework/package.json +++ b/packages/kbn-ui-framework/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "@babel/core": "^7.11.6", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/babel-preset": "1.0.0", "@kbn/optimizer": "1.0.0", "babel-loader": "^8.0.6", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 2b69d7bb226b1..4945bf88a44c5 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "23.1.1", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", "@kbn/monaco": "1.0.0", @@ -39,6 +39,7 @@ "devDependencies": { "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "css-loader": "^3.4.2", "del": "^5.1.0", "loader-utils": "^1.2.3", diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index b7d4e929ac93f..986ddba209270 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -77,6 +77,25 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ }, ], }, + { + test: !dev ? /[\\\/]@elastic[\\\/]eui[\\\/].*\.js$/ : () => false, + use: [ + { + loader: 'babel-loader', + options: { + plugins: [ + [ + require.resolve('babel-plugin-transform-react-remove-prop-types'), + { + mode: 'remove', + removeImport: true, + }, + ], + ], + }, + }, + ], + }, ], }, diff --git a/rfcs/images/background_sessions_client.png b/rfcs/images/background_sessions_client.png new file mode 100644 index 0000000000000..46cb90c93aa32 Binary files /dev/null and b/rfcs/images/background_sessions_client.png differ diff --git a/rfcs/images/background_sessions_server.png b/rfcs/images/background_sessions_server.png new file mode 100644 index 0000000000000..593db3156f879 Binary files /dev/null and b/rfcs/images/background_sessions_server.png differ diff --git a/rfcs/text/0013_background_sessions.md b/rfcs/text/0013_background_sessions.md new file mode 100644 index 0000000000000..056149e770448 --- /dev/null +++ b/rfcs/text/0013_background_sessions.md @@ -0,0 +1,489 @@ +- Start Date: (fill me in with today's date, YYYY-MM-DD) +- RFC PR: (leave this empty) +- Kibana Issue: (leave this empty) + +- Architecture diagram: https://app.lucidchart.com/documents/edit/cf35b512-616a-4734-bc72-43dde70dbd44/0_0 +- Mockups: https://www.figma.com/proto/FD2M7MUpLScJKOyYjfbmev/ES-%2F-Query-Management-v4?node-id=440%3A1&viewport=984%2C-99%2C0.09413627535104752&scaling=scale-down +- Old issue: https://github.com/elastic/kibana/issues/53335 +- Background search roadmap: https://github.com/elastic/kibana/issues/61738 +- POC: https://github.com/elastic/kibana/pull/64641 + +# Summary + +Background Sessions will enable Kibana applications and solutions to start a group of related search requests (such as those coming from a single load of a dashboard or SIEM timeline), navigate away or close the browser, then retrieve the results when they have completed. + +# Basic example + +At its core, background sessions are enabled via several new APIs, that: +- Start a session, associating multiple search requests with a single entity +- Store the session (and continue search requests in the background) +- Restore the background session + +```ts +const searchService = dataPluginStart.search; + +if (appState.sessionId) { + // If we are restoring a session, set the session ID in the search service + searchService.session.restore(sessionId); +} else { + // Otherwise, start a new background session to associate our search requests + appState.sessionId = searchService.session.start(); +} + +// Search, passing in the generated session ID. +// If this is a new session, the `search_interceptor` will associate and keep track of the async search ID with the session ID. +// If this is a restored session, the server will immediately return saved results. +// In the case where there is no saved result for a given request, or if the results have expired, `search` will throw an error with a meaningful error code. +const request = buildKibanaRequest(...); +request.sessionId = searchService.session.get(); +const response$ = await searchService.search(request); + +// Calling `session.store()`, creates a saved object for this session, allowing the user to navigate away. +// The session object will be saved with all async search IDs that were executed so far. +// Any follow up searches executed with this sessionId will be saved into this object as well. +const backgroundSession = await searchService.session.store(); +``` + +# Motivation + +Kibana is great at providing fast results from large sets of "hot" data. However, there is an increasing number of use cases where users want to analyze large amounts of "colder" data (such as year-over-year reports, historical or audit data, batch queries, etc.). + +For these cases, users run into two limitations: + 1. Kibana has a default timeout of 30s per search. This is controlled by the `elasticsearch.requestTimeout` setting (originally intended to protect clusters from unintentional overload by a single query). + 2. Kibana cancels queries upon navigating away from an application, once again, as means of protecting clusters and reducing unnecessary load. + +In 7.7, with the introduction of the `_async_search` API in Elasticsearch, we provided Kibana users a way to bypass the timeout, but users still need to remain on-screen for the entire duration of the search requests. + +The primary motivation of this RFC is to enable users to do the following without needing to keep Kibana open, or while moving onto other work inside Kibana: + +- Run long search requests (beyond 30 seconds) +- View their status (complete/incomplete) +- Cancel incomplete search requests +- Retrieve completed search request results + +# Detailed design + +Because a single view (such as a dashboard with multiple visualizations) can initiate multiple search requests, we need a way to associate the search requests together in a single entity. + +We call this entity a `session`, and when a user decides that they want to continue running the search requests while moving onto other work, we will create a saved object corresponding with that specific `session`, persisting the *sessionId* along with a mapping of each *request's hash* to the *async ID* returned by Elasticsearch. + +## High Level Flow Charts + +### Client side search + +This diagram matches any case where `data.search` is called from the front end: + +![image](../images/background_sessions_client.png) + +### Server side search + +This case happens if the server is the one to invoke the `data.search` endpoint, for example with TSVB. + +![image](../images/background_sessions_server.png) + +## Data and Saved Objects + +### Background Session Status + +```ts +export enum BackgroundSessionStatus { + Running, // The session has at least one running search ID associated with it. + Done, // All search IDs associated with this session have completed. + Error, // At least one search ID associated with this session had an error. + Expired, // The session has expired. Associated search ID data was cleared from ES. +} +``` + +### Saved Object Structure + +The saved object created for a background session will be scoped to a single space, and will be a `hidden` saved object +(so that it doesn't show in the management listings). We will provide a separate interface for users to manage their own +background sessions (which will use the `list`, `expire`, and `extend` methods described below, which will be restricted +per-user). + +```ts +interface BackgroundSessionAttributes extends SavedObjectAttributes { + sessionId: string; + userId: string; // Something unique to the user who generated this session, like username/realm-name/realm-type + status: BackgroundSessionStatus; + name: string; + creation: Date; + expiration: Date; + idMapping: { [key: string]: string }; + url: string; // A URL relative to the Kibana root to retrieve the results of a completed background session (and/or to return to an incomplete view) + metadata: { [key: string]: any } // Any data the specific application requires to restore a background session view +} +``` + +The URL that is provided will need to be generated by the specific application implementing background sessions. We +recommend using the URL generator to ensure that URLs are backwards-compatible since background sessions may exist as +long as a user continues to extend the expiration. + +## Frontend Services + +Most sessions will probably not be saved. Therefore, to avoid creating unnecessary saved objects, the browser will keep track of requests and their respective search IDs, until the user chooses to store the session. Once a session is stored, any additional searches will be immediately saved on the server side. + +### New Session Service + +We will expose a new frontend `session` service on the `data` plugin `search` service. + +The service will expose the following APIs: + +```ts +interface ISessionService { + /** + * Returns the current session ID + */ + getActiveSessionId: () => string; + + /** + * Sets the current session + * @param sessionId: The ID of the session to set + * @param isRestored: Whether or not the session is being restored + */ + setActiveSessionId: (sessionId: string, isRestored: boolean) => void; + + /** + * Start a new session, by generating a new session ID (calls `setActiveSessionId` internally) + */ + start: () => string; + + /** + * Store a session, alongside with any tracked searchIds. + * @param sessionId Session ID to store. Probably retrieved from `sessionService.get()`. + * @param name A display name for the session. + * @param url TODO: is the URL provided here? How? + * @returns The stored `BackgroundSessionAttributes` object + * @throws Throws an error in OSS. + */ + store: (sessionId: string, name: string, url: string) => Promise + + /** + * @returns Is the current session stored (i.e. is there a saved object corresponding with this sessionId). + */ + isStored: () => boolean; + + /** + * @returns Is the current session a restored session + */ + isRestored: () => boolean; + + /** + * Mark a session and and all associated searchIds as expired. + * Cancels active requests, if there are any. + * @param sessionId Session ID to store. Probably retrieved from `sessionService.get()`. + * @returns success status + * @throws Throws an error in OSS. + */ + expire: (sessionId: string) => Promise + + /** + * Extend a session and all associated searchIds. + * @param sessionId Session ID to extend. Probably retrieved from `sessionService.get()`. + * @param extendBy Time to extend by, can be a relative or absolute string. + * @returns success status + * @throws Throws an error in OSS. + */ + extend: (sessionId: string, extendBy: string)=> Promise + + /** + * @param sessionId the ID of the session to retrieve the saved object. + * @returns a filtered list of BackgroundSessionAttributes objects. + * @throws Throws an error in OSS. + */ + get: (sessionId: string) => Promise + + /** + * @param options The options to query for specific background session saved objects. + * @returns a filtered list of BackgroundSessionAttributes objects. + * @throws Throws an error in OSS. + */ + list: (options: SavedObjectsFindOptions) => Promise + + /** + * Clears out any session info as well as the current session. Called internally whenever the user navigates + * between applications. + * @internal + */ + clear: () => void; + + /** + * Track a search ID of a sessionId, if it exists. Called internally by the search service. + * @param sessionId + * @param request + * @param searchId + * @internal + */ + trackSearchId: ( + sessionId: string, + request: IKibanaSearchRequest, + searchId: string, + ) => Promise +} +``` + +## Backend Services and Routes + +The server side's feature implementation builds on how Elasticsearch's `async_search` endpoint works. When making an +initial new request to Elasticsearch, it returns a search ID that can be later used to retrieve the results. + +The server will then store that `request`, `sessionId`, and `searchId` in a mapping in memory, and periodically query +for a saved object corresponding with that session. If the saved object is found, it will update the saved object to +include this `request`/`searchId` combination, and remove it from memory. If, after a period of time (5 minutes?) the +saved object has not been found, we will stop polling for that `sessionId` and remove the `request`/`searchId` from +memory. + +When the server receives a search request that has a `sessionId` and is marked as a `restore` request, the server will +attempt to find the correct id within the saved object, and use it to retrieve the results previously saved. + +### New Session Service + +```ts +interface ISessionService { + /** + * Adds a search ID to a Background Session, if it exists. + * Also extends the expiration of the search ID to match the session's expiration. + * @param request + * @param sessionId + * @param searchId + * @returns true if id was added, false if Background Session doesn't exist or if there was an error while updating. + * @throws an error if `searchId` already exists in the mapping for this `sessionId` + */ + trackSearchId: ( + request: KibanaRequest, + sessionId: string, + searchId: string, + ) => Promise + + /** + * Get a Background Session object. + * @param request + * @param sessionId + * @returns the Background Session object if exists, or undefined. + */ + get: async ( + request: KibanaRequest, + sessionId: string + ) => Promise + + /** + * Get a searchId from a Background Session object. + * @param request + * @param sessionId + * @returns the searchID if exists on the Background Session, or undefined. + */ + getSearchId: async ( + request: KibanaRequest, + sessionId: string + ) => Promise + + /** + * Store a session. + * @param request + * @param sessionId Session ID to store. Probably retrieved from `sessionService.get()`. + * @param searchIdMap A mapping of hashed requests mapped to the corresponding searchId. + * @param url TODO: is the URL provided here? How? + * @returns The stored `BackgroundSessionAttributes` object + * @throws Throws an error in OSS. + * @internal (Consumers should use searchInterceptor.sendToBackground()) + */ + store: ( + request: KibanaRequest, + sessionId: string, + name: string, + url: string, + searchIdMapping?: Record + ) => Promise + + /** + * Mark a session as and all associated searchIds as expired. + * @param request + * @param sessionId + * @returns success status + * @throws Throws an error in OSS. + */ + expire: async ( + request: KibanaRequest, + sessionId: string + ) => Promise + + /** + * Extend a session and all associated searchIds. + * @param request + * @param sessionId + * @param extendBy Time to extend by, can be a relative or absolute string. + * @returns success status + * @throws Throws an error in OSS. + */ + extend: async ( + request: KibanaRequest, + sessionId: string, + extendBy: string, + ) => Promise + + /** + * Get a list of background session objects. + * @param request + * @param sessionId + * @returns success status + * @throws Throws an error in OSS. + */ + list: async ( + request: KibanaRequest, + ) => Promise + + /** + * Update the status of a given session + * @param request + * @param sessionId + * @param status + * @returns success status + * @throws Throws an error in OSS. + */ + updateStatus: async ( + request: KibanaRequest, + sessionId: string, + status: BackgroundSessionStatus + ) => Promise +} + +``` + +### Search Service Changes + +There are cases where search requests are issued by the server (Like TSVB). +We can simplify this flow by introducing a mechanism, similar to the frontend one, tracking the information in memory and polling for a saved object with a corresponding sessionId to store the ids into it. + +```ts +interface SearchService { + /** + * The search API will accept the option `trackId`, which will track the search ID, if available, on the server, until a corresponding saved object is created. + **/ + search: async ( + context: RequestHandlerContext, + request: IEnhancedEsSearchRequest, + options?: ISearchOptions + ) => ISearchResponse +} +``` + +### Server Routes + +Each route exposes the corresponding method from the Session Service (used only by the client-side service, not meant to be used directly by any consumers): + +`POST /internal/session/store` + +`POST /internal/session/extend` + +`POST /internal/session/expire` + +`GET /internal/session/list` + +### Search Strategy Integration + +If the `EnhancedEsSearchStrategy` receives a `restore` option, it will attempt reloading data using the Background Session saved object matching the provided `sessionId`. If there are any errors during that process, the strategy will return an error response and *not attempt to re-run the request. + +The strategy will track the asyncId on the server side, if `trackId` option is provided. + +### Monitoring Service + +The `data` plugin will register a task with the task manager, periodically monitoring the status of incomplete background sessions. + +It will query the list of all incomplete sessions, and check the status of each search that is executing. If the search requests are all complete, it will update the corresponding saved object to have a `status` of `complete`. If any of the searches return an error, it will update the saved object to an `error` state. If the search requests have expired, it will update the saved object to an `expired` state. Expired sessions will be purged once they are older than the time definedby the `EXPIRED_SESSION_TTL` advanced setting. + +Once there's a notification area in Kibana, we may use that mechanism to push completion \ error notifications to the client. + +## Miscellaneous + +#### Relative dates and restore URLs + +Restoring a sessionId depends on each request's `sha-256` hash matching exactly to the ones saved, requiring special attention to relative date ranges, as having these might yield ambiguous results. + +There are two potential scenarios: + - A relative date (for example `now-1d`) is being used in query DSL - In this case any future hash will match, but the returned data *won't match the displayed timeframe*. For example, a report might state that it shows data from yesterday, but actually show data from a week ago. + - A relative date is being translated by the application before being set to the query DSL - In this case a different date will be sent and the hash will never match, resulting in an error restoring the dashboard. + +Both scenarios require careful attention during the UI design and implementation. + +The former can be resolved by clearly displaying the creation time of the restored Background Session. We could also attempt translating relative dates to absolute one's, but this might be challenging as relative dates may appear deeply nested within the DSL. + +The latter case happens at the moment for the timepicker only: The relative date is being translated each time into an absolute one, before being sent to Elasticsearch. In order to avoid issues, we'll have to make sure that restore URLs are generated with an absolute date, to make sure they are restored correctly. + +#### Changing a restored session + +If you have restored a Background Session, making any type of change to it (time range, filters, etc.) will trigger new (potentially long) searches. There should be a clear indication in the UI that the data is no longer stored. A user then may choose to send it to background, resulting in a new Background Session being saved. + +#### Loading an errored \ expired \ canceled session + +When trying to restore a Background Session, if any of the requests hashes don't match the ones saved, or if any of the saved async search IDs are expired, a meaningful error code will be returned by the server **by those requests**. It is each application's responsibility to handle these errors appropriately. + +In such a scenario, the session will be partially restored. + +#### Extending Expiration + +Sessions are given an expiration date defined in an advanced setting (5 days by default). This expiration date is measured from the time the Background Session is saved, and it includes the time it takes to generate the results. + +A session's expiration date may be extended indefinitely. However, if a session was canceled or has already expired, it needs to be re-run. + +# Limitations + +In the first iteration, cases which require multiple search requests to be made serially will not be supported. The +following are examples of such scenarios: + +- When a visualization is configured with a terms agg with an "other" bucket +- When using blended layers or term joins in Maps + +Eventually, when expressions can be run on the server, they will run in the context of a specific `sessionId`, hence enabling those edge cases too. + +# Drawbacks + +One drawback of this approach is that we will be regularly polling Elasticsearch for saved objects, which will increase +load on the Elasticsearch server, in addition to the Kibana server (since all server-side processes share the same event +loop). We've opened https://github.com/elastic/kibana/issues/77293 to track this, and hopefully come up with benchmarks +so we feel comfortable moving forward with this approach. + +Two potential drawbacks stem from storing things in server memory. If a Kibana server is restarted, in-memory results +will be lost. (This can be an issue if a search request has started, and the user has sent to background, but the +background session saved object has not yet been updated with the search request ID.) In such cases, the user interface +will need to indicate errors for requests that were not stored in the saved object. + +There is also the consideration of the memory footprint of the Kibana server; however, since +we are only storing a hash of the request and search request ID, and are periodically cleaning it up (see Backend +Services and Routes), we do not anticipate the footprint to increase significantly. + +The results of search requests that have been sent to the background will be stored in Elasticsearch for several days, +even if they will only be retrieved once. This will be mitigated by allowing the user manually delete a background +session object after it has been accessed. + +# Alternatives + +What other designs have been considered? What is the impact of not doing this? + +# Adoption strategy + +(See "Basic example" above.) + +Any application or solution that uses the `data` plugin `search` services will be able to facilitate background sessions +fairly simply. The public side will need to create/clear sessions when appropriate, and ensure the `sessionId` is sent +with all search requests. It will also need to ensure that any necessary application data, as well as a `restoreUrl` is +sent when creating the saved object. + +The server side will just need to ensure that the `sessionId` is sent to the `search` service. If bypassing the `search` +service, it will need to also call `trackSearchId` when the first response is received, and `getSearchId` when restoring +the view. + +# How we teach this + +What names and terminology work best for these concepts and why? How is this +idea best presented? As a continuation of existing Kibana patterns? + +Would the acceptance of this proposal mean the Kibana documentation must be +re-organized or altered? Does it change how Kibana is taught to new developers +at any level? + +How should this feature be taught to existing Kibana developers? + +# Unresolved questions + +Optional, but suggested for first drafts. What parts of the design are still +TBD? diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts new file mode 100644 index 0000000000000..c443ce72f5ed7 --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -0,0 +1,153 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { BehaviorSubject } from 'rxjs'; +import { CoreUsageDataService } from './core_usage_data_service'; +import { CoreUsageData, CoreUsageDataStart } from './types'; + +const createStartContractMock = () => { + const startContract: jest.Mocked = { + getCoreUsageData: jest.fn().mockResolvedValue( + new BehaviorSubject({ + config: { + elasticsearch: { + apiVersion: 'master', + customHeadersConfigured: false, + healthCheckDelayMs: 2500, + logQueries: false, + numberOfHostsConfigured: 1, + pingTimeoutMs: 30000, + requestHeadersWhitelistConfigured: false, + requestTimeoutMs: 30000, + shardTimeoutMs: 30000, + sniffIntervalMs: -1, + sniffOnConnectionFault: false, + sniffOnStart: false, + ssl: { + alwaysPresentCertificate: false, + certificateAuthoritiesConfigured: false, + certificateConfigured: false, + keyConfigured: false, + verificationMode: 'full', + keystoreConfigured: false, + truststoreConfigured: false, + }, + }, + http: { + basePathConfigured: false, + compression: { + enabled: true, + referrerWhitelistConfigured: false, + }, + keepaliveTimeout: 120000, + maxPayloadInBytes: 1048576, + requestId: { + allowFromAnyIp: false, + ipAllowlistConfigured: false, + }, + rewriteBasePath: false, + socketTimeout: 120000, + ssl: { + certificateAuthoritiesConfigured: false, + certificateConfigured: false, + cipherSuites: [ + 'ECDHE-RSA-AES128-GCM-SHA256', + 'ECDHE-ECDSA-AES128-GCM-SHA256', + 'ECDHE-RSA-AES256-GCM-SHA384', + 'ECDHE-ECDSA-AES256-GCM-SHA384', + 'DHE-RSA-AES128-GCM-SHA256', + 'ECDHE-RSA-AES128-SHA256', + 'DHE-RSA-AES128-SHA256', + 'ECDHE-RSA-AES256-SHA384', + 'DHE-RSA-AES256-SHA384', + 'ECDHE-RSA-AES256-SHA256', + 'DHE-RSA-AES256-SHA256', + 'HIGH', + '!aNULL', + '!eNULL', + '!EXPORT', + '!DES', + '!RC4', + '!MD5', + '!PSK', + '!SRP', + '!CAMELLIA', + ], + clientAuthentication: 'none', + keyConfigured: false, + keystoreConfigured: false, + redirectHttpFromPortConfigured: false, + supportedProtocols: ['TLSv1.1', 'TLSv1.2'], + truststoreConfigured: false, + }, + xsrf: { + disableProtection: false, + whitelistConfigured: false, + }, + }, + logging: { + appendersTypesUsed: [], + loggersConfiguredCount: 0, + }, + savedObjects: { + maxImportExportSizeBytes: 10000, + maxImportPayloadBytes: 10485760, + }, + }, + environment: { + memory: { + heapSizeLimit: 1, + heapTotalBytes: 1, + heapUsedBytes: 1, + }, + }, + services: { + savedObjects: { + indices: [ + { + docsCount: 1, + docsDeleted: 1, + alias: 'test_index', + primaryStoreSizeBytes: 1, + storeSizeBytes: 1, + }, + ], + }, + }, + }) + ), + }; + + return startContract; +}; + +const createMock = () => { + const mocked: jest.Mocked> = { + setup: jest.fn(), + start: jest.fn().mockReturnValue(createStartContractMock()), + stop: jest.fn(), + }; + return mocked; +}; + +export const coreUsageDataServiceMock = { + create: createMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts new file mode 100644 index 0000000000000..a664f6514e9c8 --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -0,0 +1,259 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject, Observable } from 'rxjs'; +import { HotObservable } from 'rxjs/internal/testing/HotObservable'; +import { TestScheduler } from 'rxjs/testing'; + +import { configServiceMock } from '../config/mocks'; + +import { mockCoreContext } from '../core_context.mock'; +import { config as RawElasticsearchConfig } from '../elasticsearch/elasticsearch_config'; +import { config as RawHttpConfig } from '../http/http_config'; +import { config as RawLoggingConfig } from '../logging/logging_config'; +import { config as RawKibanaConfig } from '../kibana_config'; +import { savedObjectsConfig as RawSavedObjectsConfig } from '../saved_objects/saved_objects_config'; +import { metricsServiceMock } from '../metrics/metrics_service.mock'; +import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; + +import { CoreUsageDataService } from './core_usage_data_service'; +import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; + +describe('CoreUsageDataService', () => { + const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + let service: CoreUsageDataService; + const configService = configServiceMock.create(); + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') { + return new BehaviorSubject(RawElasticsearchConfig.schema.validate({})); + } else if (path === 'server') { + return new BehaviorSubject(RawHttpConfig.schema.validate({})); + } else if (path === 'logging') { + return new BehaviorSubject(RawLoggingConfig.schema.validate({})); + } else if (path === 'savedObjects') { + return new BehaviorSubject(RawSavedObjectsConfig.schema.validate({})); + } else if (path === 'kibana') { + return new BehaviorSubject(RawKibanaConfig.schema.validate({})); + } + return new BehaviorSubject({}); + }); + const coreContext = mockCoreContext.create({ configService }); + + beforeEach(() => { + service = new CoreUsageDataService(coreContext); + }); + + describe('start', () => { + describe('getCoreUsageData', () => { + it('returns core metrics for default config', () => { + const metrics = metricsServiceMock.createInternalSetupContract(); + service.setup({ metrics }); + const elasticsearch = elasticsearchServiceMock.createStart(); + elasticsearch.client.asInternalUser.cat.indices.mockResolvedValueOnce({ + body: [ + { + name: '.kibana_task_manager_1', + 'docs.count': 10, + 'docs.deleted': 10, + 'store.size': 1000, + 'pri.store.size': 2000, + }, + ], + } as any); + elasticsearch.client.asInternalUser.cat.indices.mockResolvedValueOnce({ + body: [ + { + name: '.kibana_1', + 'docs.count': 20, + 'docs.deleted': 20, + 'store.size': 2000, + 'pri.store.size': 4000, + }, + ], + } as any); + const typeRegistry = savedObjectsServiceMock.createTypeRegistryMock(); + typeRegistry.getAllTypes.mockReturnValue([ + { name: 'type 1', indexPattern: '.kibana' }, + { name: 'type 2', indexPattern: '.kibana_task_manager' }, + ] as any); + + const { getCoreUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + elasticsearch, + }); + expect(getCoreUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "config": Object { + "elasticsearch": Object { + "apiVersion": "master", + "customHeadersConfigured": false, + "healthCheckDelayMs": 2500, + "logQueries": false, + "numberOfHostsConfigured": 1, + "pingTimeoutMs": 30000, + "requestHeadersWhitelistConfigured": false, + "requestTimeoutMs": 30000, + "shardTimeoutMs": 30000, + "sniffIntervalMs": -1, + "sniffOnConnectionFault": false, + "sniffOnStart": false, + "ssl": Object { + "alwaysPresentCertificate": false, + "certificateAuthoritiesConfigured": false, + "certificateConfigured": false, + "keyConfigured": false, + "keystoreConfigured": false, + "truststoreConfigured": false, + "verificationMode": "full", + }, + }, + "http": Object { + "basePathConfigured": false, + "compression": Object { + "enabled": true, + "referrerWhitelistConfigured": false, + }, + "keepaliveTimeout": 120000, + "maxPayloadInBytes": 1048576, + "requestId": Object { + "allowFromAnyIp": false, + "ipAllowlistConfigured": false, + }, + "rewriteBasePath": false, + "socketTimeout": 120000, + "ssl": Object { + "certificateAuthoritiesConfigured": false, + "certificateConfigured": false, + "cipherSuites": Array [ + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "DHE-RSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES128-SHA256", + "DHE-RSA-AES128-SHA256", + "ECDHE-RSA-AES256-SHA384", + "DHE-RSA-AES256-SHA384", + "ECDHE-RSA-AES256-SHA256", + "DHE-RSA-AES256-SHA256", + "HIGH", + "!aNULL", + "!eNULL", + "!EXPORT", + "!DES", + "!RC4", + "!MD5", + "!PSK", + "!SRP", + "!CAMELLIA", + ], + "clientAuthentication": "none", + "keyConfigured": false, + "keystoreConfigured": false, + "redirectHttpFromPortConfigured": false, + "supportedProtocols": Array [ + "TLSv1.1", + "TLSv1.2", + ], + "truststoreConfigured": false, + }, + "xsrf": Object { + "disableProtection": false, + "whitelistConfigured": false, + }, + }, + "logging": Object { + "appendersTypesUsed": Array [], + "loggersConfiguredCount": 0, + }, + "savedObjects": Object { + "maxImportExportSizeBytes": 10000, + "maxImportPayloadBytes": 10485760, + }, + }, + "environment": Object { + "memory": Object { + "heapSizeLimit": 1, + "heapTotalBytes": 1, + "heapUsedBytes": 1, + }, + }, + "services": Object { + "savedObjects": Object { + "indices": Array [ + Object { + "alias": ".kibana", + "docsCount": 10, + "docsDeleted": 10, + "primaryStoreSizeBytes": 2000, + "storeSizeBytes": 1000, + }, + Object { + "alias": ".kibana_task_manager", + "docsCount": 20, + "docsDeleted": 20, + "primaryStoreSizeBytes": 4000, + "storeSizeBytes": 2000, + }, + ], + }, + }, + } + `); + }); + }); + }); + + describe('setup and stop', () => { + it('subscribes and unsubscribes from all config paths and metrics', () => { + getTestScheduler().run(({ cold, hot, expectSubscriptions }) => { + const observables: Array> = []; + configService.atPath.mockImplementation(() => { + const newObservable = hot('-a-------'); + observables.push(newObservable); + return newObservable; + }); + const metrics = metricsServiceMock.createInternalSetupContract(); + metrics.getOpsMetrics$.mockImplementation(() => { + const newObservable = hot('-a-------'); + observables.push(newObservable); + return newObservable as Observable; + }); + + service.setup({ metrics }); + + // Use the stopTimer$ to delay calling stop() until the third frame + const stopTimer$ = cold('---a|'); + stopTimer$.subscribe(() => { + service.stop(); + }); + + const subs = '^--!'; + + observables.forEach((o) => { + expectSubscriptions(o.subscriptions).toBe(subs); + }); + }); + }); + }); +}); diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts new file mode 100644 index 0000000000000..f729e23cb68bc --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -0,0 +1,285 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { CoreService } from 'src/core/types'; +import { SavedObjectsServiceStart } from 'src/core/server'; +import { CoreContext } from '../core_context'; +import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config'; +import { HttpConfigType } from '../http'; +import { LoggingConfigType } from '../logging'; +import { SavedObjectsConfigType } from '../saved_objects/saved_objects_config'; +import { CoreServicesUsageData, CoreUsageData, CoreUsageDataStart } from './types'; +import { isConfigured } from './is_configured'; +import { ElasticsearchServiceStart } from '../elasticsearch'; +import { KibanaConfigType } from '../kibana_config'; +import { MetricsServiceSetup, OpsMetrics } from '..'; + +export interface SetupDeps { + metrics: MetricsServiceSetup; +} + +export interface StartDeps { + savedObjects: SavedObjectsServiceStart; + elasticsearch: ElasticsearchServiceStart; +} + +/** + * Because users can configure their Saved Object to any arbitrary index name, + * we need to map customized index names back to a "standard" index name. + * + * e.g. If a user configures `kibana.index: .my_saved_objects` we want to the + * collected data to be grouped under `.kibana` not ".my_saved_objects". + * + * This is rather brittle, but the option to configure index names might go + * away completely anyway (see #60053). + * + * @param index The index name configured for this SO type + * @param kibanaConfigIndex The default kibana index as configured by the user + * with `kibana.index` + */ +const kibanaOrTaskManagerIndex = (index: string, kibanaConfigIndex: string) => { + return index === kibanaConfigIndex ? '.kibana' : '.kibana_task_manager'; +}; + +export class CoreUsageDataService implements CoreService { + private elasticsearchConfig?: ElasticsearchConfigType; + private configService: CoreContext['configService']; + private httpConfig?: HttpConfigType; + private loggingConfig?: LoggingConfigType; + private soConfig?: SavedObjectsConfigType; + private stop$: Subject; + private opsMetrics?: OpsMetrics; + private kibanaConfig?: KibanaConfigType; + + constructor(core: CoreContext) { + this.configService = core.configService; + this.stop$ = new Subject(); + } + + private async getSavedObjectIndicesUsageData( + savedObjects: SavedObjectsServiceStart, + elasticsearch: ElasticsearchServiceStart + ): Promise { + const indices = await Promise.all( + Array.from( + savedObjects + .getTypeRegistry() + .getAllTypes() + .reduce((acc, type) => { + const index = type.indexPattern ?? this.kibanaConfig!.index; + return index != null ? acc.add(index) : acc; + }, new Set()) + .values() + ).map((index) => { + // The _cat/indices API returns the _index_ and doesn't return a way + // to map back from the index to the alias. So we have to make an API + // call for every alias + return elasticsearch.client.asInternalUser.cat + .indices({ + index, + format: 'JSON', + bytes: 'b', + }) + .then(({ body }) => { + const stats = body[0]; + return { + alias: kibanaOrTaskManagerIndex(index, this.kibanaConfig!.index), + docsCount: stats['docs.count'], + docsDeleted: stats['docs.deleted'], + storeSizeBytes: stats['store.size'], + primaryStoreSizeBytes: stats['pri.store.size'], + }; + }); + }) + ); + + return { + indices, + }; + } + + private async getCoreUsageData( + savedObjects: SavedObjectsServiceStart, + elasticsearch: ElasticsearchServiceStart + ): Promise { + if ( + this.elasticsearchConfig == null || + this.httpConfig == null || + this.soConfig == null || + this.opsMetrics == null + ) { + throw new Error('Unable to read config values. Ensure that setup() has completed.'); + } + + const es = this.elasticsearchConfig; + const soUsageData = await this.getSavedObjectIndicesUsageData(savedObjects, elasticsearch); + + const http = this.httpConfig; + return { + config: { + elasticsearch: { + apiVersion: es.apiVersion, + sniffOnStart: es.sniffOnStart, + sniffIntervalMs: es.sniffInterval !== false ? es.sniffInterval.asMilliseconds() : -1, + sniffOnConnectionFault: es.sniffOnConnectionFault, + numberOfHostsConfigured: Array.isArray(es.hosts) + ? es.hosts.length + : isConfigured.string(es.hosts) + ? 1 + : 0, + customHeadersConfigured: isConfigured.record(es.customHeaders), + healthCheckDelayMs: es.healthCheck.delay.asMilliseconds(), + logQueries: es.logQueries, + pingTimeoutMs: es.pingTimeout.asMilliseconds(), + requestHeadersWhitelistConfigured: isConfigured.stringOrArray( + es.requestHeadersWhitelist, + ['authorization'] + ), + requestTimeoutMs: es.requestTimeout.asMilliseconds(), + shardTimeoutMs: es.shardTimeout.asMilliseconds(), + ssl: { + alwaysPresentCertificate: es.ssl.alwaysPresentCertificate, + certificateAuthoritiesConfigured: isConfigured.stringOrArray( + es.ssl.certificateAuthorities + ), + certificateConfigured: isConfigured.string(es.ssl.certificate), + keyConfigured: isConfigured.string(es.ssl.key), + verificationMode: es.ssl.verificationMode, + truststoreConfigured: isConfigured.record(es.ssl.truststore), + keystoreConfigured: isConfigured.record(es.ssl.keystore), + }, + }, + http: { + basePathConfigured: isConfigured.string(http.basePath), + maxPayloadInBytes: http.maxPayload.getValueInBytes(), + rewriteBasePath: http.rewriteBasePath, + keepaliveTimeout: http.keepaliveTimeout, + socketTimeout: http.socketTimeout, + compression: { + enabled: http.compression.enabled, + referrerWhitelistConfigured: isConfigured.array(http.compression.referrerWhitelist), + }, + xsrf: { + disableProtection: http.xsrf.disableProtection, + whitelistConfigured: isConfigured.array(http.xsrf.whitelist), + }, + requestId: { + allowFromAnyIp: http.requestId.allowFromAnyIp, + ipAllowlistConfigured: isConfigured.array(http.requestId.ipAllowlist), + }, + ssl: { + certificateAuthoritiesConfigured: isConfigured.stringOrArray( + http.ssl.certificateAuthorities + ), + certificateConfigured: isConfigured.string(http.ssl.certificate), + cipherSuites: http.ssl.cipherSuites, + keyConfigured: isConfigured.string(http.ssl.key), + redirectHttpFromPortConfigured: isConfigured.number(http.ssl.redirectHttpFromPort), + supportedProtocols: http.ssl.supportedProtocols, + clientAuthentication: http.ssl.clientAuthentication, + keystoreConfigured: isConfigured.record(http.ssl.keystore), + truststoreConfigured: isConfigured.record(http.ssl.truststore), + }, + }, + + logging: { + appendersTypesUsed: Array.from( + Array.from(this.loggingConfig?.appenders.values() ?? []) + .reduce((acc, a) => acc.add(a.kind), new Set()) + .values() + ), + loggersConfiguredCount: this.loggingConfig?.loggers.length ?? 0, + }, + + savedObjects: { + maxImportPayloadBytes: this.soConfig.maxImportPayloadBytes.getValueInBytes(), + maxImportExportSizeBytes: this.soConfig.maxImportExportSize.getValueInBytes(), + }, + }, + environment: { + memory: { + heapSizeLimit: this.opsMetrics.process.memory.heap.size_limit, + heapTotalBytes: this.opsMetrics.process.memory.heap.total_in_bytes, + heapUsedBytes: this.opsMetrics.process.memory.heap.used_in_bytes, + }, + }, + services: { + savedObjects: soUsageData, + }, + }; + } + + setup({ metrics }: SetupDeps) { + metrics + .getOpsMetrics$() + .pipe(takeUntil(this.stop$)) + .subscribe((opsMetrics) => (this.opsMetrics = opsMetrics)); + + this.configService + .atPath('elasticsearch') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.elasticsearchConfig = config; + }); + + this.configService + .atPath('server') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.httpConfig = config; + }); + + this.configService + .atPath('logging') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.loggingConfig = config; + }); + + this.configService + .atPath('savedObjects') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.soConfig = config; + }); + + this.configService + .atPath('kibana') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.kibanaConfig = config; + }); + } + + start({ savedObjects, elasticsearch }: StartDeps) { + return { + getCoreUsageData: () => { + return this.getCoreUsageData(savedObjects, elasticsearch); + }, + }; + } + + stop() { + this.stop$.next(); + this.stop$.complete(); + } +} diff --git a/src/core/server/core_usage_data/index.ts b/src/core/server/core_usage_data/index.ts new file mode 100644 index 0000000000000..b78c126657ef6 --- /dev/null +++ b/src/core/server/core_usage_data/index.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { CoreUsageDataStart } from './types'; +export { CoreUsageDataService } from './core_usage_data_service'; + +// Because of #79265 we need to explicity import, then export these types for +// scripts/telemetry_check.js to work as expected +import { + CoreUsageData, + CoreConfigUsageData, + CoreEnvironmentUsageData, + CoreServicesUsageData, +} from './types'; + +export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData }; diff --git a/src/core/server/core_usage_data/is_configured.test.ts b/src/core/server/core_usage_data/is_configured.test.ts new file mode 100644 index 0000000000000..e5d04946b8766 --- /dev/null +++ b/src/core/server/core_usage_data/is_configured.test.ts @@ -0,0 +1,137 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isConfigured } from './is_configured'; + +describe('isConfigured', () => { + describe('#string', () => { + it('returns true for a non-empty string', () => { + expect(isConfigured.string('I am configured')).toEqual(true); + }); + + it('returns false for an empty string', () => { + expect(isConfigured.string(' ')).toEqual(false); + expect(isConfigured.string(' ')).toEqual(false); + }); + + it('returns false for undefined', () => { + expect(isConfigured.string(undefined)).toEqual(false); + }); + + it('returns false for null', () => { + expect(isConfigured.string(null as any)).toEqual(false); + }); + + it('returns false for a record', () => { + expect(isConfigured.string({} as any)).toEqual(false); + expect(isConfigured.string({ key: 'hello' } as any)).toEqual(false); + }); + + it('returns false for an array', () => { + expect(isConfigured.string([] as any)).toEqual(false); + expect(isConfigured.string(['hello'] as any)).toEqual(false); + }); + }); + + describe('array', () => { + it('returns true for a non-empty array', () => { + expect(isConfigured.array(['a'])).toEqual(true); + expect(isConfigured.array([{}])).toEqual(true); + expect(isConfigured.array([{ key: 'hello' }])).toEqual(true); + }); + + it('returns false for an empty array', () => { + expect(isConfigured.array([])).toEqual(false); + }); + + it('returns false for undefined', () => { + expect(isConfigured.array(undefined)).toEqual(false); + }); + + it('returns false for null', () => { + expect(isConfigured.array(null as any)).toEqual(false); + }); + + it('returns false for a string', () => { + expect(isConfigured.array('string')).toEqual(false); + }); + + it('returns false for a record', () => { + expect(isConfigured.array({} as any)).toEqual(false); + }); + }); + + describe('stringOrArray', () => { + const arraySpy = jest.spyOn(isConfigured, 'array'); + const stringSpy = jest.spyOn(isConfigured, 'string'); + + it('calls #array for an array', () => { + isConfigured.stringOrArray([]); + expect(arraySpy).toHaveBeenCalledWith([]); + }); + + it('calls #string for non-array values', () => { + isConfigured.stringOrArray('string'); + expect(stringSpy).toHaveBeenCalledWith('string'); + }); + }); + + describe('record', () => { + it('returns true for a non-empty record', () => { + expect(isConfigured.record({ key: 'hello' })).toEqual(true); + expect(isConfigured.record({ key: undefined })).toEqual(true); + }); + + it('returns false for an empty record', () => { + expect(isConfigured.record({})).toEqual(false); + }); + it('returns false for undefined', () => { + expect(isConfigured.record(undefined)).toEqual(false); + }); + it('returns false for null', () => { + expect(isConfigured.record(null as any)).toEqual(false); + }); + }); + + describe('number', () => { + it('returns true for a valid number', () => { + expect(isConfigured.number(0)).toEqual(true); + expect(isConfigured.number(-0)).toEqual(true); + expect(isConfigured.number(1)).toEqual(true); + expect(isConfigured.number(-0)).toEqual(true); + }); + + it('returns false for NaN', () => { + expect(isConfigured.number(Number.NaN)).toEqual(false); + }); + + it('returns false for a string', () => { + expect(isConfigured.number('1' as any)).toEqual(false); + expect(isConfigured.number('' as any)).toEqual(false); + }); + + it('returns false for undefined', () => { + expect(isConfigured.number(undefined)).toEqual(false); + }); + + it('returns false for null', () => { + expect(isConfigured.number(null as any)).toEqual(false); + }); + }); +}); diff --git a/src/core/server/core_usage_data/is_configured.ts b/src/core/server/core_usage_data/is_configured.ts new file mode 100644 index 0000000000000..e66f990f1037c --- /dev/null +++ b/src/core/server/core_usage_data/is_configured.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isEqual } from 'lodash'; + +/** + * Test whether a given config value is configured based on it's schema type. + * Our configuration schema and code often accept and ignore empty values like + * `elasticsearch.customHeaders: {}`. However, for telemetry purposes, we're + * only interested when these values have been set to something that will + * change the behaviour of Kibana. + */ +export const isConfigured = { + /** + * config is a string with non-zero length + */ + string: (config?: string): boolean => { + return (config?.trim?.()?.length ?? 0) > 0; + }, + /** + * config is an array with non-zero length + */ + array: (config?: unknown[] | string, defaultValue?: any): boolean => { + return Array.isArray(config) + ? (config?.length ?? 0) > 0 && !isEqual(config, defaultValue) + : false; + }, + /** + * config is a string or array of strings where each element has non-zero length + */ + stringOrArray: (config?: string[] | string, defaultValue?: any): boolean => { + return Array.isArray(config) + ? isConfigured.array(config, defaultValue) + : isConfigured.string(config); + }, + /** + * config is a record with at least one key + */ + record: (config?: Record): boolean => { + return config != null && typeof config === 'object' && Object.keys(config).length > 0; + }, + /** + * config is a number + */ + number: (config?: number): boolean => { + // kbn-config-schema already does NaN validation, but doesn't hurt to be sure + return typeof config === 'number' && !isNaN(config); + }, +}; diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts new file mode 100644 index 0000000000000..52d2eadcf1377 --- /dev/null +++ b/src/core/server/core_usage_data/types.ts @@ -0,0 +1,162 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Type describing Core's usage data payload + * @internal + */ +export interface CoreUsageData { + config: CoreConfigUsageData; + services: CoreServicesUsageData; + environment: CoreEnvironmentUsageData; +} + +/** + * Usage data from Core services + * @internal + */ +export interface CoreServicesUsageData { + savedObjects: { + // scripts/telemetry_check.js does not support parsing Array<{...}> types + // so we have to disable eslint here and use {...}[] + // eslint-disable-next-line @typescript-eslint/array-type + indices: { + alias: string; + docsCount: number; + docsDeleted: number; + storeSizeBytes: number; + primaryStoreSizeBytes: number; + }[]; + }; +} + +/** + * Usage data on this Kibana node's runtime environment. + * @internal + */ +export interface CoreEnvironmentUsageData { + memory: { + heapTotalBytes: number; + heapUsedBytes: number; + /** V8 heap size limit */ + heapSizeLimit: number; + }; +} + +/** + * Usage data on this cluster's configuration of Core features + * @internal + */ +export interface CoreConfigUsageData { + elasticsearch: { + sniffOnStart: boolean; + sniffIntervalMs?: number; + sniffOnConnectionFault: boolean; + numberOfHostsConfigured: number; + requestHeadersWhitelistConfigured: boolean; + customHeadersConfigured: boolean; + shardTimeoutMs: number; + requestTimeoutMs: number; + pingTimeoutMs: number; + logQueries: boolean; + ssl: { + verificationMode: 'none' | 'certificate' | 'full'; + certificateAuthoritiesConfigured: boolean; + certificateConfigured: boolean; + keyConfigured: boolean; + keystoreConfigured: boolean; + truststoreConfigured: boolean; + alwaysPresentCertificate: boolean; + }; + apiVersion: string; + healthCheckDelayMs: number; + }; + + http: { + basePathConfigured: boolean; + maxPayloadInBytes: number; + rewriteBasePath: boolean; + keepaliveTimeout: number; + socketTimeout: number; + compression: { + enabled: boolean; + referrerWhitelistConfigured: boolean; + }; + xsrf: { + disableProtection: boolean; + whitelistConfigured: boolean; + }; + requestId: { + allowFromAnyIp: boolean; + ipAllowlistConfigured: boolean; + }; + ssl: { + certificateAuthoritiesConfigured: boolean; + certificateConfigured: boolean; + cipherSuites: string[]; + keyConfigured: boolean; + keystoreConfigured: boolean; + truststoreConfigured: boolean; + redirectHttpFromPortConfigured: boolean; + supportedProtocols: string[]; + clientAuthentication: 'none' | 'optional' | 'required'; + }; + }; + + logging: { + appendersTypesUsed: string[]; + loggersConfiguredCount: number; + }; + + // plugins: { + // /** list of built-in plugins that are disabled */ + // firstPartyDisabled: string[]; + // /** list of third-party plugins that are installed and enabled */ + // thirdParty: string[]; + // }; + + savedObjects: { + maxImportPayloadBytes: number; + maxImportExportSizeBytes: number; + }; + + // uiSettings: { + // overridesCount: number; + // }; +} + +/** + * Internal API for getting Core's usage data payload. + * + * @note This API should never be used to drive application logic and is only + * intended for telemetry purposes. + * + * @internal + */ +export interface CoreUsageDataStart { + /** + * Internal API for getting Core's usage data payload. + * + * @note This API should never be used to drive application logic and is only + * intended for telemetry purposes. + * + * @internal + * */ + getCoreUsageData(): Promise; +} diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 70ef93963c69f..887dc50d5f78b 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -64,6 +64,18 @@ import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { AppenderConfigType, appendersSchema, LoggingServiceSetup } from './logging'; +import { CoreUsageDataStart } from './core_usage_data'; + +// Because of #79265 we need to explicity import, then export these types for +// scripts/telemetry_check.js to work as expected +import { + CoreUsageData, + CoreConfigUsageData, + CoreEnvironmentUsageData, + CoreServicesUsageData, +} from './core_usage_data'; + +export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData }; export { AuditableEvent, Auditor, AuditorFactory, AuditTrailSetup } from './audit_trail'; export { bootstrap } from './bootstrap'; @@ -349,6 +361,8 @@ export { StatusServiceSetup, } from './status'; +export { CoreUsageDataStart } from './core_usage_data'; + /** * Plugin specific context passed to a route handler. * @@ -456,6 +470,8 @@ export interface CoreStart { uiSettings: UiSettingsServiceStart; /** {@link AuditTrailSetup} */ auditTrail: AuditTrailStart; + /** @internal {@link CoreUsageDataStart} */ + coreUsageData: CoreUsageDataStart; } export { diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index f5a5edffb0a74..ce58348a14153 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -39,6 +39,7 @@ import { InternalHttpResourcesSetup } from './http_resources'; import { InternalStatusServiceSetup } from './status'; import { AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { InternalLoggingServiceSetup } from './logging'; +import { CoreUsageDataStart } from './core_usage_data'; /** @internal */ export interface InternalCoreSetup { @@ -68,6 +69,7 @@ export interface InternalCoreStart { savedObjects: InternalSavedObjectsServiceStart; uiSettings: InternalUiSettingsServiceStart; auditTrail: AuditTrailStart; + coreUsageData: CoreUsageDataStart; } /** diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 086e20c98c1a3..75e8ae6524920 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -217,6 +217,11 @@ export class LegacyService implements CoreService { }, uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient }, auditTrail: startDeps.core.auditTrail, + coreUsageData: { + getCoreUsageData: () => { + throw new Error('core.start.coreUsageData.getCoreUsageData is unsupported in legacy'); + }, + }, }; const router = setupDeps.core.http.createRouter('', this.legacyId); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 3030cd9f4e0cb..34e85920efb24 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -37,6 +37,7 @@ import { metricsServiceMock } from './metrics/metrics_service.mock'; import { environmentServiceMock } from './environment/environment_service.mock'; import { statusServiceMock } from './status/status_service.mock'; import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; +import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; export { configServiceMock } from './config/mocks'; export { httpServerMock } from './http/http_server.mocks'; @@ -55,6 +56,7 @@ export { renderingMock } from './rendering/rendering_service.mock'; export { statusServiceMock } from './status/status_service.mock'; export { contextServiceMock } from './context/context_service.mock'; export { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; +export { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; export function pluginInitializerContextConfigMock(config: T) { const globalConfig: SharedGlobalConfig = { @@ -157,6 +159,7 @@ function createCoreStartMock() { metrics: metricsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), + coreUsageData: coreUsageDataServiceMock.createStartContract(), }; return mock; @@ -190,6 +193,7 @@ function createInternalCoreStartMock() { savedObjects: savedObjectsServiceMock.createInternalStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), auditTrail: auditTrailServiceMock.createStartContract(), + coreUsageData: coreUsageDataServiceMock.createStartContract(), }; return startDeps; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index ab3f471fd7942..a8249ed7e3218 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -251,5 +251,6 @@ export function createPluginStartContext( asScopedToClient: deps.uiSettings.asScopedToClient, }, auditTrail: deps.auditTrail, + coreUsageData: deps.coreUsageData, }; } diff --git a/src/core/server/saved_objects/saved_objects_service.mock.ts b/src/core/server/saved_objects/saved_objects_service.mock.ts index bd76658c21731..c56cdabf6e4cd 100644 --- a/src/core/server/saved_objects/saved_objects_service.mock.ts +++ b/src/core/server/saved_objects/saved_objects_service.mock.ts @@ -33,10 +33,11 @@ import { savedObjectsClientMock } from './service/saved_objects_client.mock'; import { typeRegistryMock } from './saved_objects_type_registry.mock'; import { migrationMocks } from './migrations/mocks'; import { ServiceStatusLevels } from '../status'; +import { ISavedObjectTypeRegistry } from './saved_objects_type_registry'; type SavedObjectsServiceContract = PublicMethodsOf; -const createStartContractMock = () => { +const createStartContractMock = (typeRegistry?: jest.Mocked) => { const startContrat: jest.Mocked = { getScopedClient: jest.fn(), createInternalRepository: jest.fn(), @@ -48,13 +49,15 @@ const createStartContractMock = () => { startContrat.getScopedClient.mockReturnValue(savedObjectsClientMock.create()); startContrat.createInternalRepository.mockReturnValue(savedObjectsRepositoryMock.create()); startContrat.createScopedRepository.mockReturnValue(savedObjectsRepositoryMock.create()); - startContrat.getTypeRegistry.mockReturnValue(typeRegistryMock.create()); + startContrat.getTypeRegistry.mockReturnValue(typeRegistry ?? typeRegistryMock.create()); return startContrat; }; -const createInternalStartContractMock = () => { - const internalStartContract: jest.Mocked = createStartContractMock(); +const createInternalStartContractMock = (typeRegistry?: jest.Mocked) => { + const internalStartContract: jest.Mocked = createStartContractMock( + typeRegistry + ); return internalStartContract; }; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 7742dad150cfa..a718ae8a6ff17 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -401,9 +401,102 @@ export interface ContextSetup { createContextContainer>(): IContextContainer; } +// @internal +export interface CoreConfigUsageData { + // (undocumented) + elasticsearch: { + sniffOnStart: boolean; + sniffIntervalMs?: number; + sniffOnConnectionFault: boolean; + numberOfHostsConfigured: number; + requestHeadersWhitelistConfigured: boolean; + customHeadersConfigured: boolean; + shardTimeoutMs: number; + requestTimeoutMs: number; + pingTimeoutMs: number; + logQueries: boolean; + ssl: { + verificationMode: 'none' | 'certificate' | 'full'; + certificateAuthoritiesConfigured: boolean; + certificateConfigured: boolean; + keyConfigured: boolean; + keystoreConfigured: boolean; + truststoreConfigured: boolean; + alwaysPresentCertificate: boolean; + }; + apiVersion: string; + healthCheckDelayMs: number; + }; + // (undocumented) + http: { + basePathConfigured: boolean; + maxPayloadInBytes: number; + rewriteBasePath: boolean; + keepaliveTimeout: number; + socketTimeout: number; + compression: { + enabled: boolean; + referrerWhitelistConfigured: boolean; + }; + xsrf: { + disableProtection: boolean; + whitelistConfigured: boolean; + }; + requestId: { + allowFromAnyIp: boolean; + ipAllowlistConfigured: boolean; + }; + ssl: { + certificateAuthoritiesConfigured: boolean; + certificateConfigured: boolean; + cipherSuites: string[]; + keyConfigured: boolean; + keystoreConfigured: boolean; + truststoreConfigured: boolean; + redirectHttpFromPortConfigured: boolean; + supportedProtocols: string[]; + clientAuthentication: 'none' | 'optional' | 'required'; + }; + }; + // (undocumented) + logging: { + appendersTypesUsed: string[]; + loggersConfiguredCount: number; + }; + // (undocumented) + savedObjects: { + maxImportPayloadBytes: number; + maxImportExportSizeBytes: number; + }; +} + +// @internal +export interface CoreEnvironmentUsageData { + // (undocumented) + memory: { + heapTotalBytes: number; + heapUsedBytes: number; + heapSizeLimit: number; + }; +} + // @internal (undocumented) export type CoreId = symbol; +// @internal +export interface CoreServicesUsageData { + // (undocumented) + savedObjects: { + indices: { + alias: string; + docsCount: number; + docsDeleted: number; + storeSizeBytes: number; + primaryStoreSizeBytes: number; + }[]; + }; +} + // @public export interface CoreSetup { // (undocumented) @@ -438,6 +531,8 @@ export interface CoreStart { auditTrail: AuditTrailStart; // (undocumented) capabilities: CapabilitiesStart; + // @internal (undocumented) + coreUsageData: CoreUsageDataStart; // (undocumented) elasticsearch: ElasticsearchServiceStart; // (undocumented) @@ -458,6 +553,21 @@ export interface CoreStatus { savedObjects: ServiceStatus; } +// @internal +export interface CoreUsageData { + // (undocumented) + config: CoreConfigUsageData; + // (undocumented) + environment: CoreEnvironmentUsageData; + // (undocumented) + services: CoreServicesUsageData; +} + +// @internal +export interface CoreUsageDataStart { + getCoreUsageData(): Promise; +} + // @public (undocumented) export interface CountResponse { // (undocumented) diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 600f45e0b50da..f38cac4f43768 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -48,6 +48,7 @@ import { config as statusConfig } from './status'; import { ContextService } from './context'; import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; +import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; const coreId = Symbol('core'); @@ -72,6 +73,7 @@ export class Server { private readonly logging: LoggingService; private readonly coreApp: CoreApp; private readonly auditTrail: AuditTrailService; + private readonly coreUsageData: CoreUsageDataService; #pluginsInitialized?: boolean; private coreStart?: InternalCoreStart; @@ -103,6 +105,7 @@ export class Server { this.httpResources = new HttpResourcesService(core); this.auditTrail = new AuditTrailService(core); this.logging = new LoggingService(core); + this.coreUsageData = new CoreUsageDataService(core); } public async setup() { @@ -184,6 +187,8 @@ export class Server { loggingSystem: this.loggingSystem, }); + this.coreUsageData.setup({ metrics: metricsSetup }); + const coreSetup: InternalCoreSetup = { capabilities: capabilitiesSetup, context: contextServiceSetup, @@ -235,6 +240,10 @@ export class Server { const uiSettingsStart = await this.uiSettings.start(); const metricsStart = await this.metrics.start(); const httpStart = this.http.getStartContract(); + const coreUsageDataStart = this.coreUsageData.start({ + elasticsearch: elasticsearchStart, + savedObjects: savedObjectsStart, + }); this.coreStart = { capabilities: capabilitiesStart, @@ -244,6 +253,7 @@ export class Server { savedObjects: savedObjectsStart, uiSettings: uiSettingsStart, auditTrail: auditTrailStart, + coreUsageData: coreUsageDataStart, }; const pluginsStart = await this.plugins.start(this.coreStart); diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index a5039717760ae..da282f1940662 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -40,7 +40,7 @@ export const CopySource: Task = { '!src/dev/**', '!src/setup_node_env/babel_register/index.js', '!src/setup_node_env/babel_register/register.js', - '!**/public/**', + '!**/public/**/*.{js,ts,tsx,json}', 'typings/**', 'config/kibana.yml', 'config/node.options', diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 315afd61c7c44..bf9a3b2b8a217 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -31,7 +31,12 @@ export { } from './application'; export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; -export { DashboardStart, DashboardUrlGenerator, DashboardFeatureFlagConfig } from './plugin'; +export { + DashboardSetup, + DashboardStart, + DashboardUrlGenerator, + DashboardFeatureFlagConfig, +} from './plugin'; export { DASHBOARD_APP_URL_GENERATOR, createDashboardUrlGenerator, diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index eadb3cd207e4d..52318ce2e39f3 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -145,7 +145,7 @@ interface StartDependencies { savedObjects: SavedObjectsStart; } -export type Setup = void; +export type DashboardSetup = void; export interface DashboardStart { getSavedDashboardLoader: () => SavedObjectLoader; @@ -180,7 +180,7 @@ declare module '../../../plugins/ui_actions/public' { } export class DashboardPlugin - implements Plugin { + implements Plugin { constructor(private initializerContext: PluginInitializerContext) {} private appStateUpdater = new BehaviorSubject(() => ({})); @@ -193,17 +193,8 @@ export class DashboardPlugin public setup( core: CoreSetup, - { - share, - uiActions, - embeddable, - home, - kibanaLegacy, - urlForwarding, - data, - usageCollection, - }: SetupDependencies - ): Setup { + { share, uiActions, embeddable, home, urlForwarding, data, usageCollection }: SetupDependencies + ): DashboardSetup { this.dashboardFeatureFlagConfig = this.initializerContext.config.get< DashboardFeatureFlagConfig >(); diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index aac1fe1fde212..11dcbb01bf4a6 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -214,11 +214,13 @@ export { ISearchSetup, ISearchStart, toSnakeCase, + getAsyncOptions, getDefaultSearchParams, getShardTimeout, getTotalLoaded, shimHitsTotal, usageProvider, + shimAbortSignal, SearchUsage, } from './search'; diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index e2ed500689cfa..6e185d30ad56a 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -23,7 +23,13 @@ import { Observable } from 'rxjs'; import { ApiResponse } from '@elastic/elasticsearch'; import { SearchUsage } from '../collectors/usage'; import { toSnakeCase } from './to_snake_case'; -import { ISearchStrategy, getDefaultSearchParams, getTotalLoaded, getShardTimeout } from '..'; +import { + ISearchStrategy, + getDefaultSearchParams, + getTotalLoaded, + getShardTimeout, + shimAbortSignal, +} from '..'; export const esSearchStrategyProvider = ( config$: Observable, @@ -52,10 +58,10 @@ export const esSearchStrategyProvider = ( }); try { - // Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 - const promise = context.core.elasticsearch.client.asCurrentUser.search(params); - if (options?.abortSignal) - options.abortSignal.addEventListener('abort', () => promise.abort()); + const promise = shimAbortSignal( + context.core.elasticsearch.client.asCurrentUser.search(params), + options?.abortSignal + ); const { body: rawResponse } = (await promise) as ApiResponse>; if (usage) usage.trackSuccess(rawResponse.took); diff --git a/src/plugins/data/server/search/es_search/get_default_search_params.ts b/src/plugins/data/server/search/es_search/get_default_search_params.ts index 13607fce51670..b51293b88fcec 100644 --- a/src/plugins/data/server/search/es_search/get_default_search_params.ts +++ b/src/plugins/data/server/search/es_search/get_default_search_params.ts @@ -42,3 +42,11 @@ export async function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient trackTotalHits: true, }; } + +/** + @internal + */ +export const getAsyncOptions = () => ({ + waitForCompletionTimeout: '100ms', // Wait up to 100ms for the response to return + keepAlive: '1m', // Extend the TTL for this search request by one minute +}); diff --git a/src/plugins/data/server/search/es_search/index.ts b/src/plugins/data/server/search/es_search/index.ts index 1bd17fc986168..63ab7a025ee51 100644 --- a/src/plugins/data/server/search/es_search/index.ts +++ b/src/plugins/data/server/search/es_search/index.ts @@ -21,5 +21,6 @@ export { esSearchStrategyProvider } from './es_search_strategy'; export * from './get_default_search_params'; export { getTotalLoaded } from './get_total_loaded'; export * from './to_snake_case'; +export { shimAbortSignal } from './shim_abort_signal'; export { ES_SEARCH_STRATEGY, IEsSearchRequest, IEsSearchResponse } from '../../../common'; diff --git a/src/plugins/data/server/search/es_search/shim_abort_signal.test.ts b/src/plugins/data/server/search/es_search/shim_abort_signal.test.ts new file mode 100644 index 0000000000000..794b6535cc184 --- /dev/null +++ b/src/plugins/data/server/search/es_search/shim_abort_signal.test.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { elasticsearchServiceMock } from '../../../../../core/server/mocks'; +import { shimAbortSignal } from '.'; + +describe('shimAbortSignal', () => { + it('aborts the promise if the signal is aborted', () => { + const promise = elasticsearchServiceMock.createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + shimAbortSignal(promise, controller.signal); + controller.abort(); + + expect(promise.abort).toHaveBeenCalled(); + }); + + it('returns the original promise', async () => { + const promise = elasticsearchServiceMock.createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + const response = await shimAbortSignal(promise, controller.signal); + + expect(response).toEqual(expect.objectContaining({ body: { success: true } })); + }); + + it('allows the promise to be aborted manually', () => { + const promise = elasticsearchServiceMock.createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + const enhancedPromise = shimAbortSignal(promise, controller.signal); + + enhancedPromise.abort(); + expect(promise.abort).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/data/server/search/es_search/shim_abort_signal.ts b/src/plugins/data/server/search/es_search/shim_abort_signal.ts new file mode 100644 index 0000000000000..14a4a6919c5af --- /dev/null +++ b/src/plugins/data/server/search/es_search/shim_abort_signal.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; + +/** + * + * @internal + * NOTE: Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 + * is resolved + * + * @param promise a TransportRequestPromise + * @param signal optional AbortSignal + * + * @returns a TransportRequestPromise that will be aborted if the signal is aborted + */ +export const shimAbortSignal = >( + promise: T, + signal: AbortSignal | undefined +): T => { + if (signal) { + signal.addEventListener('abort', () => promise.abort()); + } + return promise; +}; diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index 764dcd189f8db..8103b680c6bbb 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -25,7 +25,7 @@ import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src import { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source'; import { shimHitsTotal } from './shim_hits_total'; -import { getShardTimeout, getDefaultSearchParams, toSnakeCase } from '..'; +import { getShardTimeout, getDefaultSearchParams, toSnakeCase, shimAbortSignal } from '..'; /** @internal */ export function convertRequestBody( @@ -74,18 +74,17 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { const body = convertRequestBody(params.body, timeout); - // Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 - const promise = esClient.asCurrentUser.msearch( - { - body, - }, - { - querystring: toSnakeCase(defaultParams), - } + const promise = shimAbortSignal( + esClient.asCurrentUser.msearch( + { + body, + }, + { + querystring: toSnakeCase(defaultParams), + } + ), + params.signal ); - if (params.signal) { - params.signal.addEventListener('abort', () => promise.abort()); - } const response = (await promise) as ApiResponse<{ responses: Array> }>; return { diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index fed0c1a02297e..45dbdee0f846b 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -47,6 +47,7 @@ import { SearchResponse } from 'elasticsearch'; import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common'; import { ShardsResponse } from 'elasticsearch'; import { ToastInputFields } from 'src/core/public/notifications'; +import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { Unit } from '@elastic/datemath'; @@ -354,6 +355,12 @@ export type Filter = { query?: any; }; +// @internal (undocumented) +export const getAsyncOptions: () => { + waitForCompletionTimeout: string; + keepAlive: string; +}; + // Warning: (ae-forgotten-export) The symbol "IUiSettingsClient" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getDefaultSearchParams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -980,6 +987,9 @@ export interface SearchUsage { trackSuccess(duration: number): Promise; } +// @internal +export const shimAbortSignal: >(promise: T, signal: AbortSignal | undefined) => T; + // @internal export function shimHitsTotal(response: SearchResponse): { hits: { @@ -1115,19 +1125,19 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:226:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:226:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:226:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:226:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:228:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:229:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:238:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:239:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:240:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:244:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:245:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:249:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:252:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:228:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:228:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:228:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:228:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:230:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:231:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:240:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:241:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:242:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:246:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:247:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:251:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index_patterns/index_patterns_service.ts:50:14 - (ae-forgotten-export) The symbol "IndexPatternsService" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:88:66 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:78:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts index e7fafde2e68d0..4911f207f0a5f 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts @@ -18,6 +18,7 @@ */ import { find, template } from 'lodash'; +import { stringify } from 'query-string'; import $ from 'jquery'; import rison from 'rison-node'; import '../../doc_viewer'; @@ -25,7 +26,7 @@ import '../../doc_viewer'; import openRowHtml from './table_row/open.html'; import detailsHtml from './table_row/details.html'; -import { dispatchRenderComplete } from '../../../../../../kibana_utils/public'; +import { dispatchRenderComplete, url } from '../../../../../../kibana_utils/public'; import { DOC_HIDE_TIME_COLUMN_SETTING } from '../../../../../common'; import cellTemplateHtml from '../components/table_row/cell.html'; import truncateByHeightTemplateHtml from '../components/table_row/truncate_by_height.html'; @@ -49,7 +50,7 @@ interface LazyScope extends ng.IScope { [key: string]: any; } -export function createTableRowDirective($compile: ng.ICompileService, $httpParamSerializer: any) { +export function createTableRowDirective($compile: ng.ICompileService) { const cellTemplate = template(noWhiteSpace(cellTemplateHtml)); const truncateByHeightTemplate = template(noWhiteSpace(truncateByHeightTemplateHtml)); @@ -114,26 +115,25 @@ export function createTableRowDirective($compile: ng.ICompileService, $httpParam }; $scope.getContextAppHref = () => { - const path = `#/context/${encodeURIComponent($scope.indexPattern.id)}/${encodeURIComponent( - $scope.row._id - )}`; const globalFilters: any = getServices().filterManager.getGlobalFilters(); const appFilters: any = getServices().filterManager.getAppFilters(); - const hash = $httpParamSerializer({ - _g: encodeURI( - rison.encode({ + + const hash = stringify( + url.encodeQuery({ + _g: rison.encode({ filters: globalFilters || [], - }) - ), - _a: encodeURI( - rison.encode({ + }), + _a: rison.encode({ columns: $scope.columns, filters: (appFilters || []).map(esFilters.disableFilter), - }) - ), - }); + }), + }), + { encode: false, sort: false } + ); - return `${path}?${hash}`; + return `#/context/${encodeURIComponent($scope.indexPattern.id)}/${encodeURIComponent( + $scope.row._id + )}?${hash}`; }; // create a tr element that lists the value for each *column* diff --git a/src/plugins/es_ui_shared/static/forms/components/field.tsx b/src/plugins/es_ui_shared/static/forms/components/field.tsx index 78702e902ecf6..217a811168814 100644 --- a/src/plugins/es_ui_shared/static/forms/components/field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/field.tsx @@ -38,6 +38,7 @@ import { SelectField, SuperSelectField, ToggleField, + JsonEditorField, } from './fields'; const mapTypeToFieldComponent: { [key: string]: ComponentType } = { @@ -52,6 +53,7 @@ const mapTypeToFieldComponent: { [key: string]: ComponentType } = { [FIELD_TYPES.SELECT]: SelectField, [FIELD_TYPES.SUPER_SELECT]: SuperSelectField, [FIELD_TYPES.TOGGLE]: ToggleField, + [FIELD_TYPES.JSON]: JsonEditorField, }; export const Field = (props: Props) => { diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/json_editor_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/json_editor_field.tsx index fd57e098cf806..e2d80825f397e 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/json_editor_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/json_editor_field.tsx @@ -23,7 +23,7 @@ import { JsonEditor, OnJsonEditorUpdateHandler } from '../../../../public'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib'; interface Props { - field: FieldHook; + field: FieldHook; euiCodeEditorProps?: { [key: string]: any }; [key: string]: any; } @@ -44,7 +44,7 @@ export const JsonEditorField = ({ field, ...rest }: Props) => { ['validations']; - children: (args: { - items: ArrayItem[]; - error: string | null; - addItem: () => void; - removeItem: (id: number) => void; - moveItem: (sourceIdx: number, destinationIdx: number) => void; - form: FormHook; - }) => JSX.Element; + validations?: FieldConfig['validations']; + children: (formFieldArray: FormArrayField) => JSX.Element; } export interface ArrayItem { @@ -45,6 +38,15 @@ export interface ArrayItem { isNew: boolean; } +export interface FormArrayField { + items: ArrayItem[]; + error: string | null; + addItem: () => void; + removeItem: (id: number) => void; + moveItem: (sourceIdx: number, destinationIdx: number) => void; + form: FormHook; +} + /** * Use UseArray to dynamically add fields to your form. * @@ -71,7 +73,7 @@ export const UseArray = ({ const uniqueId = useRef(0); const form = useFormContext(); - const { getFieldDefaultValue } = form; + const { __getFieldDefaultValue } = form; const getNewItemAtIndex = useCallback( (index: number): ArrayItem => ({ @@ -84,7 +86,7 @@ export const UseArray = ({ const fieldDefaultValue = useMemo(() => { const defaultValues = readDefaultValueOnForm - ? (getFieldDefaultValue(path) as any[]) + ? (__getFieldDefaultValue(path) as any[]) : undefined; const getInitialItemsFromValues = (values: any[]): ArrayItem[] => @@ -97,17 +99,23 @@ export const UseArray = ({ return defaultValues ? getInitialItemsFromValues(defaultValues) : new Array(initialNumberOfItems).fill('').map((_, i) => getNewItemAtIndex(i)); - }, [path, initialNumberOfItems, readDefaultValueOnForm, getFieldDefaultValue, getNewItemAtIndex]); + }, [ + path, + initialNumberOfItems, + readDefaultValueOnForm, + __getFieldDefaultValue, + getNewItemAtIndex, + ]); // Create a new hook field with the "hasValue" set to false so we don't use its value to build the final form data. // Apart from that the field behaves like a normal field and is hooked into the form validation lifecycle. - const fieldConfigBase: FieldConfig & InternalFieldConfig = { + const fieldConfigBase: FieldConfig & InternalFieldConfig = { defaultValue: fieldDefaultValue, - errorDisplayDelay: 0, + valueChangeDebounceTime: 0, isIncludedInOutput: false, }; - const fieldConfig: FieldConfig & InternalFieldConfig = validations + const fieldConfig: FieldConfig & InternalFieldConfig = validations ? { validations, ...fieldConfigBase } : fieldConfigBase; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx index 6b913f246abbb..a3a0984d4a736 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx @@ -19,23 +19,23 @@ import React, { FunctionComponent } from 'react'; -import { FieldHook, FieldConfig } from '../types'; +import { FieldHook, FieldConfig, FormData } from '../types'; import { useField } from '../hooks'; import { useFormContext } from '../form_context'; -export interface Props { +export interface Props { path: string; - config?: FieldConfig; + config?: FieldConfig; defaultValue?: T; - component?: FunctionComponent | 'input'; + component?: FunctionComponent; componentProps?: Record; readDefaultValueOnForm?: boolean; - onChange?: (value: T) => void; - children?: (field: FieldHook) => JSX.Element; + onChange?: (value: I) => void; + children?: (field: FieldHook) => JSX.Element; [key: string]: any; } -function UseFieldComp(props: Props) { +function UseFieldComp(props: Props) { const { path, config, @@ -48,18 +48,16 @@ function UseFieldComp(props: Props) { ...rest } = props; - const form = useFormContext(); + const form = useFormContext(); const ComponentToRender = component ?? 'input'; - // For backward compatibility we merge the "componentProps" prop into the "rest" - const propsToForward = - componentProps !== undefined ? { ...componentProps, ...rest } : { ...rest }; + const propsToForward = { ...componentProps, ...rest }; - const fieldConfig: FieldConfig & { initialValue?: T } = + const fieldConfig: FieldConfig & { initialValue?: T } = config !== undefined ? { ...config } : ({ ...form.__readFieldConfigFromSchema(path), - } as Partial>); + } as Partial>); if (defaultValue !== undefined) { // update the form "defaultValue" ref object so when/if we reset the form we can go back to this value @@ -70,21 +68,12 @@ function UseFieldComp(props: Props) { } else { if (readDefaultValueOnForm) { // Read the field initial value from the "defaultValue" object passed to the form - fieldConfig.initialValue = (form.getFieldDefaultValue(path) as T) ?? fieldConfig.defaultValue; + fieldConfig.initialValue = + (form.__getFieldDefaultValue(path) as T) ?? fieldConfig.defaultValue; } } - if (!fieldConfig.path) { - (fieldConfig.path as any) = path; - } else { - if (fieldConfig.path !== path) { - throw new Error( - `Field path mismatch. Got "${path}" but field config has "${fieldConfig.path}".` - ); - } - } - - const field = useField(form, path, fieldConfig, onChange); + const field = useField(form, path, fieldConfig, onChange); // Children prevails over anything else provided. if (children) { @@ -111,9 +100,13 @@ export const UseField = React.memo(UseFieldComp) as typeof UseFieldComp; * Get a component providing some common props for all instances. * @param partialProps Partial props to apply to all instances */ -export function getUseField(partialProps: Partial>) { - return function (props: Partial>) { - const componentProps = { ...partialProps, ...props } as Props; - return {...componentProps} />; +export function getUseField( + partialProps: Partial> +) { + return function ( + props: Partial> + ) { + const componentProps = { ...partialProps, ...props } as Props; + return {...componentProps} />; }; } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx index d69527e36249b..20f4608352d94 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx @@ -22,27 +22,27 @@ import React from 'react'; import { UseField, Props as UseFieldProps } from './use_field'; import { FieldHook } from '../types'; -type FieldsArray = Array<{ id: string } & Omit, 'children'>>; +type FieldsArray = Array<{ id: string } & Omit, 'children'>>; -interface Props { - fields: { [key: string]: Exclude, 'children'> }; - children: (fields: { [key: string]: FieldHook }) => JSX.Element; +interface Props { + fields: { [K in keyof T]: Exclude, 'children'> }; + children: (fields: { [K in keyof T]: FieldHook }) => JSX.Element; } -export const UseMultiFields = ({ fields, children }: Props) => { +export function UseMultiFields({ fields, children }: Props) { const fieldsArray = Object.entries(fields).reduce( - (acc, [fieldId, field]) => [...acc, { id: fieldId, ...field }], + (acc, [fieldId, field]) => [...acc, { id: fieldId, ...(field as FieldHook) }], [] as FieldsArray ); - const hookFields: { [key: string]: FieldHook } = {}; + const hookFields: { [K in keyof T]: FieldHook } = {} as any; const renderField = (index: number) => { const { id } = fieldsArray[index]; return ( - + {(field) => { - hookFields[id] = field; + hookFields[id as keyof T] = field; return index === fieldsArray.length - 1 ? children(hookFields) : renderField(index + 1); }} @@ -54,4 +54,4 @@ export const UseMultiFields = ({ fields, children }: Props) => { } return renderField(0); -}; +} diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts index 4056947483107..3a2ffdc3af146 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts @@ -30,11 +30,15 @@ export const FIELD_TYPES = { SELECT: 'select', SUPER_SELECT: 'superSelect', MULTI_SELECT: 'multiSelect', + JSON: 'json', }; // Validation types export const VALIDATION_TYPES = { - FIELD: 'field', // Default validation error (on the field value) - ASYNC: 'async', // Returned from asynchronous validations - ARRAY_ITEM: 'arrayItem', // If the field value is an Array, this error would be returned if an _item_ of the array is invalid + /** Default validation error (on the field value) */ + FIELD: 'field', + /** Returned from asynchronous validations */ + ASYNC: 'async', + /** If the field value is an Array, this error type would be returned if an _item_ of the array is invalid */ + ARRAY_ITEM: 'arrayItem', }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx index 0e6a75e9c5065..0670220ccd0c9 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx @@ -22,8 +22,8 @@ import React, { createContext, useContext, useMemo } from 'react'; import { FormData, FormHook } from './types'; import { Subject } from './lib'; -export interface Context { - getFormData$: () => Subject; +export interface Context { + getFormData$: () => Subject; getFormData: FormHook['getFormData']; } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index bb4aae6eccae8..7b21b6638aeac 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -19,7 +19,14 @@ import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; -import { FormHook, FieldHook, FieldConfig, FieldValidateResponse, ValidationError } from '../types'; +import { + FormHook, + FieldHook, + FieldConfig, + FieldValidateResponse, + ValidationError, + FormData, +} from '../types'; import { FIELD_TYPES, VALIDATION_TYPES } from '../constants'; export interface InternalFieldConfig { @@ -27,11 +34,11 @@ export interface InternalFieldConfig { isIncludedInOutput?: boolean; } -export const useField = ( - form: FormHook, +export const useField = ( + form: FormHook, path: string, - config: FieldConfig & InternalFieldConfig = {}, - valueChangeListener?: (value: T) => void + config: FieldConfig & InternalFieldConfig = {}, + valueChangeListener?: (value: I) => void ) => { const { type = FIELD_TYPES.TEXT, @@ -44,7 +51,7 @@ export const useField = ( validations, formatters, fieldsToValidateOnChange, - errorDisplayDelay = form.__options.errorDisplayDelay, + valueChangeDebounceTime = form.__options.valueChangeDebounceTime, serializer, deserializer, } = config; @@ -68,7 +75,7 @@ export const useField = ( [initialValue, deserializer] ); - const [value, setStateValue] = useState(deserializeValue); + const [value, setStateValue] = useState(deserializeValue); const [errors, setErrors] = useState([]); const [isPristine, setPristine] = useState(true); const [isValidating, setValidating] = useState(false); @@ -84,9 +91,9 @@ export const useField = ( // -- HELPERS // ---------------------------------- - const serializeValue: FieldHook['__serializeValue'] = useCallback( - (rawValue = value) => { - return serializer ? serializer(rawValue) : rawValue; + const serializeValue: FieldHook['__serializeValue'] = useCallback( + (internalValue: I = value) => { + return serializer ? serializer(internalValue) : ((internalValue as unknown) as T); }, [serializer, value] ); @@ -129,16 +136,8 @@ export const useField = ( const changeIteration = ++changeCounter.current; const startTime = Date.now(); - if (debounceTimeout.current) { - clearTimeout(debounceTimeout.current); - debounceTimeout.current = null; - } - setPristine(false); - - if (errorDisplayDelay > 0) { - setIsChangingValue(true); - } + setIsChangingValue(true); // Notify listener if (valueChangeListener) { @@ -161,22 +160,24 @@ export const useField = ( * and then, we verify how long we've already waited for as form.__validateFields() is asynchronous * and might already have taken more than the specified delay) */ - if (errorDisplayDelay > 0 && changeIteration === changeCounter.current) { - const delta = Date.now() - startTime; - if (delta < errorDisplayDelay) { - debounceTimeout.current = setTimeout(() => { - debounceTimeout.current = null; - setIsChangingValue(false); - }, errorDisplayDelay - delta); - } else { - setIsChangingValue(false); + if (changeIteration === changeCounter.current) { + if (valueChangeDebounceTime > 0) { + const delta = Date.now() - startTime; + if (delta < valueChangeDebounceTime) { + debounceTimeout.current = setTimeout(() => { + debounceTimeout.current = null; + setIsChangingValue(false); + }, valueChangeDebounceTime - delta); + return; + } } + setIsChangingValue(false); } }, [ path, value, valueChangeListener, - errorDisplayDelay, + valueChangeDebounceTime, fieldsToValidateOnChange, __updateFormDataAt, __validateFields, @@ -207,7 +208,7 @@ export const useField = ( validationTypeToValidate, }: { formData: any; - value: T; + value: I; validationTypeToValidate?: string; }): ValidationError[] | Promise => { if (!validations) { @@ -339,7 +340,7 @@ export const useField = ( * If a validationType is provided then only that validation will be executed, * skipping the other type of validation that might exist. */ - const validate: FieldHook['validate'] = useCallback( + const validate: FieldHook['validate'] = useCallback( (validationData = {}) => { const { formData = getFormData({ unflatten: false }), @@ -392,14 +393,14 @@ export const useField = ( * * @param newValue The new value to assign to the field */ - const setValue: FieldHook['setValue'] = useCallback( + const setValue: FieldHook['setValue'] = useCallback( (newValue) => { setStateValue((prev) => { - let formattedValue: T; + let formattedValue: I; if (typeof newValue === 'function') { - formattedValue = formatInputValue((newValue as Function)(prev)); + formattedValue = formatInputValue((newValue as Function)(prev)); } else { - formattedValue = formatInputValue(newValue); + formattedValue = formatInputValue(newValue); } return formattedValue; }); @@ -407,7 +408,7 @@ export const useField = ( [formatInputValue] ); - const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { + const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { setErrors( _errors.map((error) => ({ validationType: VALIDATION_TYPES.FIELD, @@ -422,13 +423,13 @@ export const useField = ( * * @param event Form input change event */ - const onChange: FieldHook['onChange'] = useCallback( + const onChange: FieldHook['onChange'] = useCallback( (event) => { const newValue = {}.hasOwnProperty.call(event!.target, 'checked') ? event.target.checked : event.target.value; - setValue((newValue as unknown) as T); + setValue((newValue as unknown) as I); }, [setValue] ); @@ -443,7 +444,7 @@ export const useField = ( * * @param validationType The validation type to return error messages from */ - const getErrorsMessages: FieldHook['getErrorsMessages'] = useCallback( + const getErrorsMessages: FieldHook['getErrorsMessages'] = useCallback( (args = {}) => { const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args; const errorMessages = errors.reduce((messages, error) => { @@ -464,30 +465,64 @@ export const useField = ( [errors] ); - const reset: FieldHook['reset'] = useCallback( + /** + * Handler to update the state and make sure the component is still mounted. + * When resetting the form, some field might get unmounted (e.g. a toggle on "true" becomes "false" and now certain fields should not be in the DOM). + * In that scenario there is a race condition in the "reset" method below, because the useState() hook is not synchronous. + * + * A better approach would be to have the state in a reducer and being able to update all values in a single dispatch action. + */ + const updateStateIfMounted = useCallback( + ( + state: 'isPristine' | 'isValidating' | 'isChangingValue' | 'isValidated' | 'errors' | 'value', + nextValue: any + ) => { + if (isMounted.current === false) { + return; + } + + switch (state) { + case 'value': + return setValue(nextValue); + case 'errors': + return setErrors(nextValue); + case 'isChangingValue': + return setIsChangingValue(nextValue); + case 'isPristine': + return setPristine(nextValue); + case 'isValidated': + return setIsValidated(nextValue); + case 'isValidating': + return setValidating(nextValue); + } + }, + [setValue] + ); + + const reset: FieldHook['reset'] = useCallback( (resetOptions = { resetValue: true }) => { const { resetValue = true, defaultValue: updatedDefaultValue } = resetOptions; - setPristine(true); - setValidating(false); - setIsChangingValue(false); - setIsValidated(false); - setErrors([]); + updateStateIfMounted('isPristine', true); + updateStateIfMounted('isValidating', false); + updateStateIfMounted('isChangingValue', false); + updateStateIfMounted('isValidated', false); + updateStateIfMounted('errors', []); if (resetValue) { hasBeenReset.current = true; const newValue = deserializeValue(updatedDefaultValue ?? defaultValue); - setValue(newValue); + updateStateIfMounted('value', newValue); return newValue; } }, - [setValue, deserializeValue, defaultValue] + [updateStateIfMounted, deserializeValue, defaultValue] ); // Don't take into account non blocker validation. Some are just warning (like trying to add a wrong ComboBox item) const isValid = errors.filter((e) => e.__isBlocking__ !== false).length === 0; - const field = useMemo>(() => { + const field = useMemo>(() => { return { path, type, @@ -565,6 +600,7 @@ export const useField = ( return () => { if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); + debounceTimeout.current = null; } }; }, [onValueChange]); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx index edcd84daf5d2f..b28c09d07fa98 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -196,7 +196,9 @@ describe('useForm() hook', () => { }); expect(isValid).toBe(false); - expect(data).toEqual({}); // Don't build the object (and call the serializers()) when invalid + // If the form is not valid, we don't build the final object to avoid + // calling the serializer(s) with invalid values. + expect(data).toEqual({}); }); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index b390c17d3c2ff..be4535fec3669 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -24,19 +24,18 @@ import { set } from '@elastic/safer-lodash-set'; import { FormHook, FieldHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types'; import { mapFormFields, unflattenObject, Subject, Subscription } from '../lib'; -const DEFAULT_ERROR_DISPLAY_TIMEOUT = 500; const DEFAULT_OPTIONS = { - errorDisplayDelay: DEFAULT_ERROR_DISPLAY_TIMEOUT, + valueChangeDebounceTime: 500, stripEmptyFields: true, }; -interface UseFormReturn { - form: FormHook; +interface UseFormReturn { + form: FormHook; } -export function useForm( - formConfig?: FormConfig -): UseFormReturn { +export function useForm( + formConfig?: FormConfig +): UseFormReturn { const { onSubmit, schema, serializer, deserializer, options, id = 'default', defaultValue } = formConfig ?? {}; @@ -48,9 +47,9 @@ export function useForm( const filtered = Object.entries(_defaultValue as object) .filter(({ 1: value }) => value !== undefined) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as T); - return deserializer ? (deserializer(filtered) as any) : filtered; + return deserializer ? deserializer(filtered) : filtered; }, [deserializer] ); @@ -61,13 +60,13 @@ export function useForm( const defaultValueDeserialized = useRef(defaultValueMemoized); - const { errorDisplayDelay, stripEmptyFields: doStripEmptyFields } = options ?? {}; + const { valueChangeDebounceTime, stripEmptyFields: doStripEmptyFields } = options ?? {}; const formOptions = useMemo( () => ({ stripEmptyFields: doStripEmptyFields ?? DEFAULT_OPTIONS.stripEmptyFields, - errorDisplayDelay: errorDisplayDelay ?? DEFAULT_OPTIONS.errorDisplayDelay, + valueChangeDebounceTime: valueChangeDebounceTime ?? DEFAULT_OPTIONS.valueChangeDebounceTime, }), - [errorDisplayDelay, doStripEmptyFields] + [valueChangeDebounceTime, doStripEmptyFields] ); const [isSubmitted, setIsSubmitted] = useState(false); @@ -93,7 +92,7 @@ export function useForm( return formData$.current; }, []); - const fieldsToArray = useCallback(() => Object.values(fieldsRefs.current), []); + const fieldsToArray = useCallback<() => FieldHook[]>(() => Object.values(fieldsRefs.current), []); const getFieldsForOutput = useCallback( (fields: FieldsMap, opts: { stripEmptyFields: boolean }): FieldsMap => { @@ -144,7 +143,7 @@ export function useForm( }); const fieldsValue = mapFormFields(fieldsToOutput, (field) => field.__serializeValue()); return serializer - ? (serializer(unflattenObject(fieldsValue)) as T) + ? (serializer(unflattenObject(fieldsValue) as I) as T) : (unflattenObject(fieldsValue) as T); } @@ -175,6 +174,24 @@ export function useForm( const isFieldValid = (field: FieldHook) => field.isValid && !field.isValidating; + const waitForFieldsToFinishValidating = useCallback(async () => { + let areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating); + if (!areSomeFieldValidating) { + return; + } + + return new Promise((resolve) => { + setTimeout(() => { + areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating); + if (areSomeFieldValidating) { + // Recursively wait for all the fields to finish validating. + return waitForFieldsToFinishValidating().then(resolve); + } + resolve(); + }, 100); + }); + }, [fieldsToArray]); + const validateFields: FormHook['__validateFields'] = useCallback( async (fieldNames) => { const fieldsToValidate = fieldNames @@ -204,18 +221,25 @@ export function useForm( // To know the current form validity, we will then merge the "validationResult" _with_ the fieldsRefs object state, // the "validationResult" taking presedence over the fieldsRefs values. const formFieldsValidity = fieldsToArray().map((field) => { + const hasUpdatedValidity = validationResultByPath[field.path] !== undefined; const _isValid = validationResultByPath[field.path] ?? field.isValid; - const _isValidated = - validationResultByPath[field.path] !== undefined ? true : field.isValidated; - return [_isValid, _isValidated]; + const _isValidated = hasUpdatedValidity ? true : field.isValidated; + const _isValidating = hasUpdatedValidity ? false : field.isValidating; + return { + isValid: _isValid, + isValidated: _isValidated, + isValidating: _isValidating, + }; }); - const areAllFieldsValidated = formFieldsValidity.every(({ 1: isValidated }) => isValidated); + const areAllFieldsValidated = formFieldsValidity.every((field) => field.isValidated); + const areSomeFieldValidating = formFieldsValidity.some((field) => field.isValidating); // If *not* all the fiels have been validated, the validity of the form is unknown, thus still "undefined" - const isFormValid = areAllFieldsValidated - ? formFieldsValidity.every(([_isValid]) => _isValid) - : undefined; + const isFormValid = + areAllFieldsValidated && areSomeFieldValidating === false + ? formFieldsValidity.every((field) => field.isValid) + : undefined; setIsValid(isFormValid); @@ -225,6 +249,14 @@ export function useForm( ); const validateAllFields = useCallback(async (): Promise => { + // Maybe some field are being validated because of their async validation(s). + // We make sure those validations have finished executing before proceeding. + await waitForFieldsToFinishValidating(); + + if (!isMounted.current) { + return false; + } + const fieldsArray = fieldsToArray(); const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated); @@ -238,7 +270,7 @@ export function useForm( setIsValid(isFormValid); return isFormValid!; - }, [fieldsToArray, validateFields]); + }, [fieldsToArray, validateFields, waitForFieldsToFinishValidating]); const addField: FormHook['__addField'] = useCallback( (field) => { @@ -303,7 +335,7 @@ export function useForm( const getFields: FormHook['getFields'] = useCallback(() => fieldsRefs.current, []); - const getFieldDefaultValue: FormHook['getFieldDefaultValue'] = useCallback( + const getFieldDefaultValue: FormHook['__getFieldDefaultValue'] = useCallback( (fieldName) => get(defaultValueDeserialized.current, fieldName), [] ); @@ -410,13 +442,13 @@ export function useForm( getFields, getFormData, getErrors, - getFieldDefaultValue, reset, __options: formOptions, __getFormData$: getFormData$, __updateFormDataAt: updateFormDataAt, __updateDefaultValueAt: updateDefaultValueAt, __readFieldConfigFromSchema: readFieldConfigFromSchema, + __getFieldDefaultValue: getFieldDefaultValue, __addField: addField, __removeField: removeField, __validateFields: validateFields, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts index fb4a0984438ad..6c6dee3624979 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts @@ -63,7 +63,11 @@ export const useFormData = (options: Options = {}): ? (watch as string[]) : ([watch] as string[]); - if (valuesToWatchArray.some((value) => previousRawData.current[value] !== raw[value])) { + if ( + valuesToWatchArray.some( + (value) => previousRawData.current[value] !== raw[value as keyof T] + ) + ) { previousRawData.current = raw; // Only update the state if one of the field we watch has changed. setFormData(raw); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index 18b8f478f7c0e..ae731caff2881 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -24,21 +24,37 @@ import { Subject, Subscription } from './lib'; // Comes from https://github.com/microsoft/TypeScript/issues/15012#issuecomment-365453623 type Required = T extends FormData ? { [P in keyof T]-?: NonNullable } : T; -export interface FormHook { +export interface FormHook { + /** Flag that indicates if the form has been submitted at least once. It is set to `true` when we call `submit()`. */ readonly isSubmitted: boolean; + /** Flag that indicates if the form is being submitted. */ readonly isSubmitting: boolean; + /** Flag that indicates if the form is valid. If `undefined` then the form validation has not been checked yet. */ readonly isValid: boolean | undefined; + /** The form id. If none was provided, "default" will be returned. */ readonly id: string; + /** + * This handler submits the form and returns its data and validity. If the form is not valid, the data will be `null` + * as only valid data is passed through the `serializer(s)` before being returned. + */ submit: (e?: FormEvent | MouseEvent) => Promise<{ data: T; isValid: boolean }>; + /** Use this handler to get the validity of the form. */ validate: () => Promise; subscribe: (handler: OnUpdateHandler) => Subscription; + /** Sets a field value imperatively. */ setFieldValue: (fieldName: string, value: FieldValue) => void; + /** Sets a field errors imperatively. */ setFieldErrors: (fieldName: string, errors: ValidationError[]) => void; + /** Access any field on the form. */ getFields: () => FieldsMap; + /** + * Return the form data. It accepts an optional options object with an `unflatten` parameter (defaults to `true`). + * If you are only interested in the raw form data, pass `unflatten: false` to the handler + */ getFormData: (options?: { unflatten?: boolean }) => T; - getFieldDefaultValue: (fieldName: string) => unknown; - /* Returns a list of all errors in the form */ + /* Returns an array with of all errors in the form. */ getErrors: () => string[]; + /** Resets the form to its initial state. */ reset: (options?: { resetValues?: boolean; defaultValue?: Partial }) => void; readonly __options: Required; __getFormData$: () => Subject; @@ -50,23 +66,19 @@ export interface FormHook { __updateFormDataAt: (field: string, value: unknown) => T; __updateDefaultValueAt: (field: string, value: unknown) => void; __readFieldConfigFromSchema: (fieldName: string) => FieldConfig; + __getFieldDefaultValue: (fieldName: string) => unknown; } -export interface FormSchema { - [key: string]: FormSchemaEntry; -} - -type FormSchemaEntry = - | FieldConfig - | Array> - | { [key: string]: FieldConfig | Array> | FormSchemaEntry }; +export type FormSchema = { + [K in keyof T]?: FieldConfig | FormSchema; +}; -export interface FormConfig { +export interface FormConfig { onSubmit?: FormSubmitHandler; - schema?: FormSchema; + schema?: FormSchema; defaultValue?: Partial; - serializer?: SerializerFunc; - deserializer?: SerializerFunc; + serializer?: SerializerFunc; + deserializer?: SerializerFunc; options?: FormOptions; id?: string; } @@ -83,20 +95,20 @@ export interface OnFormUpdateArg { export type OnUpdateHandler = (arg: OnFormUpdateArg) => void; export interface FormOptions { - errorDisplayDelay?: number; + valueChangeDebounceTime?: number; /** * Remove empty string field ("") from form data */ stripEmptyFields?: boolean; } -export interface FieldHook { +export interface FieldHook { readonly path: string; readonly label?: string; readonly labelAppend?: string | ReactNode; readonly helpText?: string | ReactNode; readonly type: string; - readonly value: T; + readonly value: I; readonly errors: ValidationError[]; readonly isValid: boolean; readonly isPristine: boolean; @@ -108,34 +120,33 @@ export interface FieldHook { errorCode?: string; }) => string | null; onChange: (event: ChangeEvent<{ name?: string; value: string; checked?: boolean }>) => void; - setValue: (value: T | ((prevValue: T) => T)) => void; + setValue: (value: I | ((prevValue: I) => I)) => void; setErrors: (errors: ValidationError[]) => void; clearErrors: (type?: string | string[]) => void; validate: (validateData?: { formData?: any; - value?: T; + value?: I; validationType?: string; }) => FieldValidateResponse | Promise; reset: (options?: { resetValue?: boolean; defaultValue?: T }) => unknown | undefined; // Flag to indicate if the field value will be included in the form data outputted // when calling form.getFormData(); __isIncludedInOutput: boolean; - __serializeValue: (rawValue?: unknown) => unknown; + __serializeValue: (internalValue?: I) => T; } -export interface FieldConfig { - readonly path?: string; +export interface FieldConfig { readonly label?: string; readonly labelAppend?: string | ReactNode; readonly helpText?: string | ReactNode; - readonly type?: HTMLInputElement['type']; - readonly defaultValue?: ValueType; - readonly validations?: Array>; + readonly type?: string; + readonly defaultValue?: T; + readonly validations?: Array>; readonly formatters?: FormatterFunc[]; - readonly deserializer?: SerializerFunc; - readonly serializer?: SerializerFunc; + readonly deserializer?: SerializerFunc; + readonly serializer?: SerializerFunc; readonly fieldsToValidateOnChange?: string[]; - readonly errorDisplayDelay?: number; + readonly valueChangeDebounceTime?: number; } export interface FieldsMap { @@ -166,7 +177,7 @@ export interface ValidationFuncArg { errors: readonly ValidationError[]; } -export type ValidationFunc = ( +export type ValidationFunc = ( data: ValidationFuncArg ) => ValidationError | void | undefined | Promise | void | undefined>; @@ -187,8 +198,12 @@ type FormatterFunc = (value: any, formData: FormData) => unknown; // string | number | boolean | string[] ... type FieldValue = unknown; -export interface ValidationConfig { - validator: ValidationFunc; +export interface ValidationConfig< + FormType extends FormData = any, + Error extends string = string, + ValueType = unknown +> { + validator: ValidationFunc; type?: string; /** * By default all validation are blockers, which means that if they fail, the field is invalid. diff --git a/src/plugins/kibana_usage_collection/README.md b/src/plugins/kibana_usage_collection/README.md index 73a4d53f305f2..69711d30cdc74 100644 --- a/src/plugins/kibana_usage_collection/README.md +++ b/src/plugins/kibana_usage_collection/README.md @@ -8,3 +8,4 @@ This plugin registers the basic usage collectors from Kibana: - Number of Saved Objects per type - Non-default UI Settings - CSP configuration +- Core Metrics diff --git a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap b/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap index 47a4c458a8398..c479562795512 100644 --- a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap +++ b/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap @@ -11,3 +11,5 @@ exports[`kibana_usage_collection Runs the setup method without issues 4`] = `fal exports[`kibana_usage_collection Runs the setup method without issues 5`] = `false`; exports[`kibana_usage_collection Runs the setup method without issues 6`] = `true`; + +exports[`kibana_usage_collection Runs the setup method without issues 7`] = `false`; diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts new file mode 100644 index 0000000000000..297baf016e9e6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -0,0 +1,132 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { CoreUsageData, CoreUsageDataStart } from '../../../../../core/server'; + +export function getCoreUsageCollector( + usageCollection: UsageCollectionSetup, + getCoreUsageDataService: () => CoreUsageDataStart +) { + return usageCollection.makeUsageCollector({ + type: 'core', + isReady: () => typeof getCoreUsageDataService() !== 'undefined', + schema: { + config: { + elasticsearch: { + sniffOnStart: { type: 'boolean' }, + sniffIntervalMs: { type: 'long' }, + sniffOnConnectionFault: { type: 'boolean' }, + numberOfHostsConfigured: { type: 'long' }, + requestHeadersWhitelistConfigured: { type: 'boolean' }, + customHeadersConfigured: { type: 'boolean' }, + shardTimeoutMs: { type: 'long' }, + requestTimeoutMs: { type: 'long' }, + pingTimeoutMs: { type: 'long' }, + logQueries: { type: 'boolean' }, + ssl: { + verificationMode: { type: 'keyword' }, + certificateAuthoritiesConfigured: { type: 'boolean' }, + certificateConfigured: { type: 'boolean' }, + keyConfigured: { type: 'boolean' }, + keystoreConfigured: { type: 'boolean' }, + truststoreConfigured: { type: 'boolean' }, + alwaysPresentCertificate: { type: 'boolean' }, + }, + apiVersion: { type: 'keyword' }, + healthCheckDelayMs: { type: 'long' }, + }, + + http: { + basePathConfigured: { type: 'boolean' }, + maxPayloadInBytes: { type: 'long' }, + rewriteBasePath: { type: 'boolean' }, + keepaliveTimeout: { type: 'long' }, + socketTimeout: { type: 'long' }, + compression: { + enabled: { type: 'boolean' }, + referrerWhitelistConfigured: { type: 'boolean' }, + }, + xsrf: { + disableProtection: { type: 'boolean' }, + whitelistConfigured: { type: 'boolean' }, + }, + requestId: { + allowFromAnyIp: { type: 'boolean' }, + ipAllowlistConfigured: { type: 'boolean' }, + }, + ssl: { + certificateAuthoritiesConfigured: { type: 'boolean' }, + certificateConfigured: { type: 'boolean' }, + cipherSuites: { type: 'array', items: { type: 'keyword' } }, + keyConfigured: { type: 'boolean' }, + keystoreConfigured: { type: 'boolean' }, + truststoreConfigured: { type: 'boolean' }, + redirectHttpFromPortConfigured: { type: 'boolean' }, + supportedProtocols: { type: 'array', items: { type: 'keyword' } }, + clientAuthentication: { type: 'keyword' }, + }, + }, + + logging: { + appendersTypesUsed: { type: 'array', items: { type: 'keyword' } }, + loggersConfiguredCount: { type: 'long' }, + }, + + savedObjects: { + maxImportPayloadBytes: { type: 'long' }, + maxImportExportSizeBytes: { type: 'long' }, + }, + }, + environment: { + memory: { + heapSizeLimit: { type: 'long' }, + heapTotalBytes: { type: 'long' }, + heapUsedBytes: { type: 'long' }, + }, + }, + services: { + savedObjects: { + indices: { + type: 'array', + items: { + docsCount: { type: 'long' }, + docsDeleted: { type: 'long' }, + alias: { type: 'text' }, + primaryStoreSizeBytes: { type: 'long' }, + storeSizeBytes: { type: 'long' }, + }, + }, + }, + }, + }, + fetch() { + return getCoreUsageDataService().getCoreUsageData(); + }, + }); +} + +export function registerCoreUsageCollector( + usageCollection: UsageCollectionSetup, + getCoreUsageDataService: () => CoreUsageDataStart +) { + usageCollection.registerCollector( + getCoreUsageCollector(usageCollection, getCoreUsageDataService) + ); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts new file mode 100644 index 0000000000000..b712e9ebbce48 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + CollectorOptions, + createUsageCollectionSetupMock, +} from '../../../../usage_collection/server/usage_collection.mock'; + +import { registerCoreUsageCollector } from '.'; +import { coreUsageDataServiceMock } from '../../../../../core/server/mocks'; +import { CoreUsageData } from 'src/core/server/'; + +describe('telemetry_core', () => { + let collector: CollectorOptions; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = config; + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const callCluster = jest.fn().mockImplementation(() => ({})); + const coreUsageDataStart = coreUsageDataServiceMock.createStartContract(); + const getCoreUsageDataReturnValue = (Symbol('core telemetry') as any) as CoreUsageData; + coreUsageDataStart.getCoreUsageData.mockResolvedValue(getCoreUsageDataReturnValue); + + beforeAll(() => registerCoreUsageCollector(usageCollectionMock, () => coreUsageDataStart)); + + test('registered collector is set', () => { + expect(collector).not.toBeUndefined(); + expect(collector.type).toBe('core'); + }); + + test('fetch', async () => { + expect(await collector.fetch(callCluster)).toEqual(getCoreUsageDataReturnValue); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.ts b/src/plugins/kibana_usage_collection/server/collectors/core/index.ts new file mode 100644 index 0000000000000..79a4b83b41355 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/core/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { registerCoreUsageCollector } from './core_usage_collector'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 1f9fe130fa45d..2408dc84c2e56 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -23,3 +23,4 @@ export { registerApplicationUsageCollector } from './application_usage'; export { registerKibanaUsageCollector } from './kibana'; export { registerOpsStatsCollector } from './ops_stats'; export { registerCspCollector } from './csp'; +export { registerCoreUsageCollector } from './core'; diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index 260acd19ab516..198fdbb7a8703 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -31,6 +31,7 @@ import { SavedObjectsServiceSetup, OpsMetrics, Logger, + CoreUsageDataStart, } from '../../../core/server'; import { registerApplicationUsageCollector, @@ -39,6 +40,7 @@ import { registerOpsStatsCollector, registerUiMetricUsageCollector, registerCspCollector, + registerCoreUsageCollector, } from './collectors'; interface KibanaUsageCollectionPluginsDepsSetup { @@ -53,6 +55,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { private savedObjectsClient?: ISavedObjectsRepository; private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; + private coreUsageData?: CoreUsageDataStart; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); @@ -72,6 +75,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { const savedObjectsClient = new SavedObjectsClient(this.savedObjectsClient); this.uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); core.metrics.getOpsMetrics$().subscribe(this.metric$); + this.coreUsageData = core.coreUsageData; } public stop() { @@ -86,6 +90,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { ) { const getSavedObjectsClient = () => this.savedObjectsClient; const getUiSettingsClient = () => this.uiSettingsClient; + const getCoreUsageDataService = () => this.coreUsageData!; registerOpsStatsCollector(usageCollection, metric$); registerKibanaUsageCollector(usageCollection, this.legacyConfig$); @@ -98,5 +103,6 @@ export class KibanaUsageCollectionPlugin implements Plugin { getSavedObjectsClient ); registerCspCollector(usageCollection, coreSetup.http); + registerCoreUsageCollector(usageCollection, getCoreUsageDataService); } } diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 976ddd789ad22..230be399febda 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,8 +1,3 @@ .kbnTopNavMenu { margin-right: $euiSizeXS; } - -.kbnTopNavMenu > * > * { - // TEMP fix to adjust spacing between EuiHeaderList__list items - margin: 0 $euiSizeXS; -} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 212bc19208ca8..147feee3cd472 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -164,10 +164,6 @@ describe('TopNavMenu', () => { // menu is rendered outside of the component expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); - - const buttons = portalTarget.querySelectorAll('button'); - expect(buttons.length).toBe(menuItems.length + 1); // should be n+1 buttons in mobile for popover button - expect(buttons[buttons.length - 1].getAttribute('aria-label')).toBe('Open navigation menu'); // last button should be mobile button }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index a27addeb14393..1739b7d915adb 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -88,7 +88,7 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { function renderMenu(className: string): ReactElement | null { if (!config || config.length === 0) return null; return ( - + {renderItems()} ); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index e503ebb839f48..5c463902f77f5 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -52,7 +52,7 @@ export function TopNavMenuItem(props: TopNavMenuData) { {upperFirst(props.label || props.id!)} ) : ( - + {upperFirst(props.label || props.id!)} ); diff --git a/src/plugins/region_map/public/region_map_type.js b/src/plugins/region_map/public/region_map_type.js index 4cd30d32698ed..ec32d582ce15b 100644 --- a/src/plugins/region_map/public/region_map_type.js +++ b/src/plugins/region_map/public/region_map_type.js @@ -32,7 +32,7 @@ export function createRegionMapTypeDefinition(dependencies) { return { name: 'region_map', - getDeprecationMessage, + getInfoMessage: getDeprecationMessage, title: i18n.translate('regionMap.mapVis.regionMapTitle', { defaultMessage: 'Region Map' }), description: i18n.translate('regionMap.mapVis.regionMapDescription', { defaultMessage: diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index e3d6c41a278cd..950ecebeaadc7 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -40,4 +40,6 @@ export { import { SharePlugin } from './plugin'; +export { KibanaURL } from './kibana_url'; + export const plugin = () => new SharePlugin(); diff --git a/src/plugins/share/public/kibana_url.ts b/src/plugins/share/public/kibana_url.ts new file mode 100644 index 0000000000000..40c3372579f6a --- /dev/null +++ b/src/plugins/share/public/kibana_url.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// TODO: Replace this logic with KibanaURL once it is available. +// https://github.com/elastic/kibana/issues/64497 +export class KibanaURL { + public readonly path: string; + public readonly appName: string; + public readonly appPath: string; + + constructor(path: string) { + const match = path.match(/^.*\/app\/([^\/#]+)(.+)$/); + + if (!match) { + throw new Error('Unexpected URL path.'); + } + + const [, appName, appPath] = match; + + if (!appName || !appPath) { + throw new Error('Could not parse URL path.'); + } + + this.path = path; + this.appName = appName; + this.appPath = appPath; + } +} diff --git a/src/plugins/share/public/types.ts b/src/plugins/share/public/types.ts index 9dcfc3d9e8143..19f33a820a11a 100644 --- a/src/plugins/share/public/types.ts +++ b/src/plugins/share/public/types.ts @@ -18,7 +18,8 @@ */ import { ComponentType } from 'react'; -import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; +import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; /** * @public @@ -53,7 +54,8 @@ export interface ShareContext { * used to order the individual items in a flat list returned by all registered * menu providers. * */ -export interface ShareContextMenuPanelItem extends Omit { +export interface ShareContextMenuPanelItem + extends Omit { name: string; // EUI will accept a `ReactNode` for the `name` prop, but `ShareContentMenu` assumes a `string`. sortOrder?: number; } diff --git a/src/plugins/telemetry/server/fetcher.test.ts b/src/plugins/telemetry/server/fetcher.test.ts index 245adf59799cc..45712df772e1c 100644 --- a/src/plugins/telemetry/server/fetcher.test.ts +++ b/src/plugins/telemetry/server/fetcher.test.ts @@ -23,19 +23,93 @@ import { coreMock } from '../../../core/server/mocks'; describe('FetcherTask', () => { describe('sendIfDue', () => { - it('returns undefined and warns when it fails to get telemetry configs', async () => { + it('stops when it fails to get telemetry configs', async () => { const initializerContext = coreMock.createPluginInitializerContext({}); const fetcherTask = new FetcherTask(initializerContext); const mockError = new Error('Some message.'); - fetcherTask['getCurrentConfigs'] = async () => { - throw mockError; - }; + const getCurrentConfigs = jest.fn().mockRejectedValue(mockError); + const fetchTelemetry = jest.fn(); + const sendTelemetry = jest.fn(); + Object.assign(fetcherTask, { + getCurrentConfigs, + fetchTelemetry, + sendTelemetry, + }); const result = await fetcherTask['sendIfDue'](); expect(result).toBe(undefined); + expect(getCurrentConfigs).toBeCalledTimes(1); + expect(fetchTelemetry).toBeCalledTimes(0); + expect(sendTelemetry).toBeCalledTimes(0); expect(fetcherTask['logger'].warn).toBeCalledTimes(1); expect(fetcherTask['logger'].warn).toHaveBeenCalledWith( - `Error fetching telemetry configs: ${mockError}` + `Error getting telemetry configs. (${mockError})` ); }); + + it('stops when all collectors are not ready', async () => { + const initializerContext = coreMock.createPluginInitializerContext({}); + const fetcherTask = new FetcherTask(initializerContext); + const getCurrentConfigs = jest.fn().mockResolvedValue({}); + const areAllCollectorsReady = jest.fn().mockResolvedValue(false); + const shouldSendReport = jest.fn().mockReturnValue(true); + const fetchTelemetry = jest.fn(); + const sendTelemetry = jest.fn(); + const updateReportFailure = jest.fn(); + + Object.assign(fetcherTask, { + getCurrentConfigs, + areAllCollectorsReady, + shouldSendReport, + fetchTelemetry, + updateReportFailure, + sendTelemetry, + }); + + await fetcherTask['sendIfDue'](); + + expect(fetchTelemetry).toBeCalledTimes(0); + expect(sendTelemetry).toBeCalledTimes(0); + + expect(areAllCollectorsReady).toBeCalledTimes(1); + expect(updateReportFailure).toBeCalledTimes(0); + expect(fetcherTask['logger'].warn).toBeCalledTimes(1); + expect(fetcherTask['logger'].warn).toHaveBeenCalledWith( + `Error fetching usage. (Error: Not all collectors are ready.)` + ); + }); + + it('fetches usage and send telemetry', async () => { + const initializerContext = coreMock.createPluginInitializerContext({}); + const fetcherTask = new FetcherTask(initializerContext); + const mockTelemetryUrl = 'mock_telemetry_url'; + const mockClusters = ['cluster_1', 'cluster_2']; + const getCurrentConfigs = jest.fn().mockResolvedValue({ + telemetryUrl: mockTelemetryUrl, + }); + const areAllCollectorsReady = jest.fn().mockResolvedValue(true); + const shouldSendReport = jest.fn().mockReturnValue(true); + + const fetchTelemetry = jest.fn().mockResolvedValue(mockClusters); + const sendTelemetry = jest.fn(); + const updateReportFailure = jest.fn(); + + Object.assign(fetcherTask, { + getCurrentConfigs, + areAllCollectorsReady, + shouldSendReport, + fetchTelemetry, + updateReportFailure, + sendTelemetry, + }); + + await fetcherTask['sendIfDue'](); + + expect(areAllCollectorsReady).toBeCalledTimes(1); + expect(fetchTelemetry).toBeCalledTimes(1); + expect(sendTelemetry).toBeCalledTimes(2); + expect(sendTelemetry).toHaveBeenNthCalledWith(1, mockTelemetryUrl, mockClusters[0]); + expect(sendTelemetry).toHaveBeenNthCalledWith(2, mockTelemetryUrl, mockClusters[1]); + expect(updateReportFailure).toBeCalledTimes(0); + }); }); }); diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index 75cfac721bcd3..e6d909965f5f7 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -22,7 +22,10 @@ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; // @ts-ignore import fetch from 'node-fetch'; -import { TelemetryCollectionManagerPluginStart } from 'src/plugins/telemetry_collection_manager/server'; +import { + TelemetryCollectionManagerPluginStart, + UsageStatsPayload, +} from 'src/plugins/telemetry_collection_manager/server'; import { PluginInitializerContext, Logger, @@ -94,6 +97,10 @@ export class FetcherTask { } } + private async areAllCollectorsReady() { + return (await this.telemetryCollectionManager?.areAllCollectorsReady()) ?? false; + } + private async sendIfDue() { if (this.isSending) { return; @@ -103,7 +110,7 @@ export class FetcherTask { try { telemetryConfig = await this.getCurrentConfigs(); } catch (err) { - this.logger.warn(`Error fetching telemetry configs: ${err}`); + this.logger.warn(`Error getting telemetry configs. (${err})`); return; } @@ -111,9 +118,22 @@ export class FetcherTask { return; } + let clusters: Array = []; + this.isSending = true; + + try { + const allCollectorsReady = await this.areAllCollectorsReady(); + if (!allCollectorsReady) { + throw new Error('Not all collectors are ready.'); + } + clusters = await this.fetchTelemetry(); + } catch (err) { + this.logger.warn(`Error fetching usage. (${err})`); + this.isSending = false; + return; + } + try { - this.isSending = true; - const clusters = await this.fetchTelemetry(); const { telemetryUrl } = telemetryConfig; for (const cluster of clusters) { await this.sendTelemetry(telemetryUrl, cluster); @@ -123,7 +143,7 @@ export class FetcherTask { } catch (err) { await this.updateReportFailure(telemetryConfig); - this.logger.warn(`Error sending telemetry usage data: ${err}`); + this.logger.warn(`Error sending telemetry usage data. (${err})`); } this.isSending = false; } diff --git a/src/plugins/telemetry_collection_manager/server/index.ts b/src/plugins/telemetry_collection_manager/server/index.ts index 8761c28e14095..36ab64731fe58 100644 --- a/src/plugins/telemetry_collection_manager/server/index.ts +++ b/src/plugins/telemetry_collection_manager/server/index.ts @@ -38,4 +38,5 @@ export { ClusterDetails, ClusterDetailsGetter, LicenseGetter, + UsageStatsPayload, } from './types'; diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index e54e7451a670a..ff63262004cf5 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -67,6 +67,7 @@ export class TelemetryCollectionManagerPlugin setCollection: this.setCollection.bind(this), getOptInStats: this.getOptInStats.bind(this), getStats: this.getStats.bind(this), + areAllCollectorsReady: this.areAllCollectorsReady.bind(this), }; } @@ -75,6 +76,7 @@ export class TelemetryCollectionManagerPlugin setCollection: this.setCollection.bind(this), getOptInStats: this.getOptInStats.bind(this), getStats: this.getStats.bind(this), + areAllCollectorsReady: this.areAllCollectorsReady.bind(this), }; } @@ -185,6 +187,10 @@ export class TelemetryCollectionManagerPlugin return []; } + private areAllCollectorsReady = async () => { + return await this.usageCollection?.areAllCollectorsReady(); + }; + private getOptInStatsForCollection = async ( collection: Collection, optInStatus: boolean, diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts index 44970df30fd16..3b0936fb73a60 100644 --- a/src/plugins/telemetry_collection_manager/server/types.ts +++ b/src/plugins/telemetry_collection_manager/server/types.ts @@ -34,6 +34,7 @@ export interface TelemetryCollectionManagerPluginSetup { ) => void; getOptInStats: TelemetryCollectionManagerPlugin['getOptInStats']; getStats: TelemetryCollectionManagerPlugin['getStats']; + areAllCollectorsReady: TelemetryCollectionManagerPlugin['areAllCollectorsReady']; } export interface TelemetryCollectionManagerPluginStart { @@ -42,6 +43,7 @@ export interface TelemetryCollectionManagerPluginStart { ) => void; getOptInStats: TelemetryCollectionManagerPlugin['getOptInStats']; getStats: TelemetryCollectionManagerPlugin['getStats']; + areAllCollectorsReady: TelemetryCollectionManagerPlugin['areAllCollectorsReady']; } export interface TelemetryOptInStats { diff --git a/src/plugins/tile_map/public/tile_map_type.js b/src/plugins/tile_map/public/tile_map_type.js index cc19a8bbcef91..411eaa96d8bfe 100644 --- a/src/plugins/tile_map/public/tile_map_type.js +++ b/src/plugins/tile_map/public/tile_map_type.js @@ -33,7 +33,7 @@ export function createTileMapTypeDefinition(dependencies) { return { name: 'tile_map', - getDeprecationMessage, + getInfoMessage: getDeprecationMessage, title: i18n.translate('tileMap.vis.mapTitle', { defaultMessage: 'Coordinate Map', }), diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 9955f9fac81ca..aae633a956c48 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -325,3 +325,8 @@ By storing these metrics and their counts as key-value pairs, we can add more me to worry about exceeding the 1000-field soft limit in Elasticsearch. The only caveat is that it makes it harder to consume in Kibana when analysing each entry in the array separately. In the telemetry team we are working to find a solution to this. + +# Routes registered by this plugin + +- `/api/ui_metric/report`: Used by `ui_metrics` usage collector instances to report their usage data to the server +- `/api/stats`: Get the metrics and usage ([details](./server/routes/stats/README.md)) diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 6861be7f4f76b..7bf4e19c72cc0 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -76,23 +76,27 @@ export class CollectorSet { }; public areAllCollectorsReady = async (collectorSet: CollectorSet = this) => { - // Kept this for runtime validation in JS code. if (!(collectorSet instanceof CollectorSet)) { throw new Error( `areAllCollectorsReady method given bad collectorSet parameter: ` + typeof collectorSet ); } - const collectorTypesNotReady = ( - await Promise.all( - [...collectorSet.collectors.values()].map(async (collector) => { - if (!(await collector.isReady())) { - return collector.type; - } - }) - ) - ).filter((collectorType): collectorType is string => !!collectorType); - const allReady = collectorTypesNotReady.length === 0; + const collectors = [...collectorSet.collectors.values()]; + const collectorsWithStatus = await Promise.all( + collectors.map(async (collector) => { + return { + isReady: await collector.isReady(), + collector, + }; + }) + ); + + const collectorsTypesNotReady = collectorsWithStatus + .filter((collectorWithStatus) => collectorWithStatus.isReady === false) + .map((collectorWithStatus) => collectorWithStatus.collector.type); + + const allReady = collectorsTypesNotReady.length === 0; if (!allReady && this.maximumWaitTimeForAllCollectorsInS >= 0) { const nowTimestamp = +new Date(); @@ -102,10 +106,11 @@ export class CollectorSet { const timeLeftInMS = this.maximumWaitTimeForAllCollectorsInS * 1000 - timeWaitedInMS; if (timeLeftInMS <= 0) { this.logger.debug( - `All collectors are not ready (waiting for ${collectorTypesNotReady.join(',')}) ` + + `All collectors are not ready (waiting for ${collectorsTypesNotReady.join(',')}) ` + `but we have waited the required ` + `${this.maximumWaitTimeForAllCollectorsInS}s and will return data from all collectors that are ready.` ); + return true; } else { this.logger.debug(`All collectors are not ready. Waiting for ${timeLeftInMS}ms longer.`); diff --git a/src/plugins/usage_collection/server/routes/stats/README.md b/src/plugins/usage_collection/server/routes/stats/README.md new file mode 100644 index 0000000000000..09dabefbab44a --- /dev/null +++ b/src/plugins/usage_collection/server/routes/stats/README.md @@ -0,0 +1,20 @@ +# `/api/stats` + +This API returns the metrics for the Kibana server and usage stats. It allows the [Metricbeat Kibana module](https://www.elastic.co/guide/en/beats/metricbeat/current/metricbeat-module-kibana.html) to collect the [stats metricset](https://www.elastic.co/guide/en/beats/metricbeat/current/metricbeat-metricset-kibana-stats.html). + +By default, it returns the simplest level of stats; consisting of the Kibana server's ops metrics, version, status, and basic config like the server name, host, port, and locale. + +However, the information detailed above can be extended, with the combination of the following 3 query parameters: + +| Query Parameter | Default value | Description | +|:----------------|:-------------:|:------------| +|`extended`|`false`|When `true`, it adds `clusterUuid` and `usage`. The latter contains the information reported by all the Usage Collectors registered in the Kibana server. It may throw `503 Stats not ready` if any of the collectors is not fully initialized yet.| +|`legacy`|`false`|By default, when `extended=true`, the key names of the data in `usage` are transformed into API-friendlier `snake_case` format (i.e.: `clusterUuid` is transformed to `cluster_uuid`). When this parameter is `true`, the data is returned as-is.| +|`exclude_usage`|`false`|When `true`, and `extended=true`, it will report `clusterUuid` but no `usage`.| + +## Known use cases + +Metricbeat Kibana' stats metricset ([code](https://github.com/elastic/beats/blob/master/metricbeat/module/kibana/stats/stats.go)) uses this API to collect the metrics (every 10s) and usage (only once every 24h), and then reports them to the Monitoring cluster. They call this API in 2 ways: + +1. Metrics-only collection (every 10 seconds): `GET /api/stats?extended=true&legacy=true&exclude_usage=true` +2. Metrics+usage (every 24 hours): `GET /api/stats?extended=true&legacy=true&exclude_usage=false` diff --git a/src/plugins/usage_collection/server/routes/stats/index.ts b/src/plugins/usage_collection/server/routes/stats/index.ts new file mode 100644 index 0000000000000..8871ee599e56b --- /dev/null +++ b/src/plugins/usage_collection/server/routes/stats/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { registerStatsRoute } from './stats'; diff --git a/src/plugins/usage_collection/server/routes/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts similarity index 91% rename from src/plugins/usage_collection/server/routes/stats.ts rename to src/plugins/usage_collection/server/routes/stats/stats.ts index ef5da2eb11ba6..bee25fef669f1 100644 --- a/src/plugins/usage_collection/server/routes/stats.ts +++ b/src/plugins/usage_collection/server/routes/stats/stats.ts @@ -30,8 +30,8 @@ import { MetricsServiceSetup, ServiceStatus, ServiceStatusLevels, -} from '../../../../core/server'; -import { CollectorSet } from '../collector'; +} from '../../../../../core/server'; +import { CollectorSet } from '../../collector'; const STATS_NOT_READY_MESSAGE = i18n.translate('usageCollection.stats.notReadyMessage', { defaultMessage: 'Stats are not ready yet. Please try again later.', @@ -101,10 +101,12 @@ export function registerStatsRoute({ if (isExtended) { const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const esClient = context.core.elasticsearch.client.asCurrentUser; - const collectorsReady = await collectorSet.areAllCollectorsReady(); - if (shouldGetUsage && !collectorsReady) { - return res.customError({ statusCode: 503, body: { message: STATS_NOT_READY_MESSAGE } }); + if (shouldGetUsage) { + const collectorsReady = await collectorSet.areAllCollectorsReady(); + if (!collectorsReady) { + return res.customError({ statusCode: 503, body: { message: STATS_NOT_READY_MESSAGE } }); + } } const usagePromise = shouldGetUsage ? getUsage(callCluster, esClient) : Promise.resolve({}); @@ -152,9 +154,8 @@ export function registerStatsRoute({ } } - // Guranteed to resolve immediately due to replay effect on getOpsMetrics$ - // eslint-disable-next-line @typescript-eslint/naming-convention - const { collected_at, ...lastMetrics } = await metrics + // Guaranteed to resolve immediately due to replay effect on getOpsMetrics$ + const { collected_at: collectedAt, ...lastMetrics } = await metrics .getOpsMetrics$() .pipe(first()) .toPromise(); @@ -173,7 +174,7 @@ export function registerStatsRoute({ snapshot: SNAPSHOT_REGEX.test(config.kibanaVersion), status: ServiceStatusToLegacyState[overallStatus.level.toString()], }, - last_updated: collected_at.toISOString(), + last_updated: collectedAt.toISOString(), collection_interval_in_millis: metrics.collectionInterval, }); diff --git a/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx b/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx new file mode 100644 index 0000000000000..4f8bc50bb1b3b --- /dev/null +++ b/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parse } from 'hjson'; +import React from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Vis } from '../../../visualizations/public'; + +function ExperimentalMapLayerInfo() { + const title = ( + + GitHub + + ), + }} + /> + ); + + return ( + + ); +} + +export const getInfoMessage = (vis: Vis) => { + if (vis.params.spec) { + try { + const spec = parse(vis.params.spec, { legacyRoot: false, keepWsc: true }); + + if (spec.config?.kibana?.type === 'map') { + return ; + } + } catch (e) { + // spec is invalid + } + } + + return null; +}; diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index 46fd2fbc5587e..a9651c1f5eb33 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -29,6 +29,8 @@ import { getDefaultSpec } from './default_spec'; import { createInspectorAdapters } from './vega_inspector'; import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; +import { getInfoMessage } from './components/experimental_map_vis_info'; + export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependencies) => { const requestHandler = createVegaRequestHandler(dependencies); const visualization = createVegaVisualization(dependencies); @@ -36,6 +38,7 @@ export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependen return { name: 'vega', title: 'Vega', + getInfoMessage, description: i18n.translate('visTypeVega.type.vegaDescription', { defaultMessage: 'Create custom visualizations using Vega and Vega-Lite', description: 'Vega and Vega-Lite are product names and should not be translated', diff --git a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx index 524792d1460fe..0cc737f19e5c6 100644 --- a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx +++ b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx @@ -134,34 +134,6 @@ describe('MetricsAxisOptions component', () => { const updatedSeries = [{ ...chart, data: { id: agg.id, label: agg.makeLabel() } }]; expect(setValue).toHaveBeenCalledWith(SERIES_PARAMS, updatedSeries); }); - - it('should update visType when one seriesParam', () => { - const comp = mount(); - expect(defaultProps.vis.type.type).toBe(ChartTypes.AREA); - - comp.setProps({ - stateParams: { - ...defaultProps.stateParams, - seriesParams: [{ ...chart, type: ChartTypes.LINE }], - }, - }); - - expect(defaultProps.vis.setState).toHaveBeenLastCalledWith({ type: ChartTypes.LINE }); - }); - - it('should set histogram visType when multiple seriesParam', () => { - const comp = mount(); - expect(defaultProps.vis.type.type).toBe(ChartTypes.AREA); - - comp.setProps({ - stateParams: { - ...defaultProps.stateParams, - seriesParams: [chart, { ...chart, type: ChartTypes.LINE }], - }, - }); - - expect(defaultProps.vis.setState).toHaveBeenLastCalledWith({ type: ChartTypes.HISTOGRAM }); - }); }); describe('updateAxisTitle', () => { diff --git a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx index d885f8fb0b12f..18687404b9114 100644 --- a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx +++ b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx @@ -18,7 +18,7 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { cloneDeep, uniq, get } from 'lodash'; +import { cloneDeep, get } from 'lodash'; import { EuiSpacer } from '@elastic/eui'; import { IAggConfig } from 'src/plugins/data/public'; @@ -293,15 +293,6 @@ function MetricsAxisOptions(props: ValidationVisOptionsProps) updateAxisTitle(updatedSeries); }, [metrics, firstValueAxesId, setValue, stateParams.seriesParams, updateAxisTitle]); - const visType = useMemo(() => { - const types = uniq(stateParams.seriesParams.map(({ type }) => type)); - return types.length === 1 ? types[0] : 'histogram'; - }, [stateParams.seriesParams]); - - useEffect(() => { - vis.setState({ ...vis.serialize(), type: visType }); - }, [vis, visType]); - return isTabSelected ? ( <> ) { const { stateParams, setValue, vis } = props; + const currentChartTypes = useMemo(() => uniq(stateParams.seriesParams.map(({ type }) => type)), [ + stateParams.seriesParams, + ]); + return ( <> @@ -68,7 +73,7 @@ function PointSeriesOptions(props: ValidationVisOptionsProps) /> )} - {vis.type.name === ChartTypes.HISTOGRAM && ( + {currentChartTypes.includes(ChartTypes.HISTOGRAM) && ( Adapters); isDeprecated?: boolean; - getDeprecationMessage?: (vis: Vis) => ReactElement<{}>; + getInfoMessage?: (vis: Vis) => ReactElement<{}> | null; } interface ExpressionBaseVisTypeOptions extends CommonBaseVisTypeOptions { @@ -84,7 +84,7 @@ export class BaseVisType { useCustomNoDataScreen: boolean; inspectorAdapters?: Adapters | (() => Adapters); toExpressionAst?: VisToExpressionAst; - getDeprecationMessage?: (vis: Vis) => ReactElement<{}>; + getInfoMessage?: (vis: Vis) => ReactElement<{}> | null; constructor(opts: BaseVisTypeOptions) { if (!opts.icon && !opts.image) { @@ -122,7 +122,7 @@ export class BaseVisType { this.useCustomNoDataScreen = opts.useCustomNoDataScreen || false; this.inspectorAdapters = opts.inspectorAdapters; this.toExpressionAst = opts.toExpressionAst; - this.getDeprecationMessage = opts.getDeprecationMessage; + this.getInfoMessage = opts.getInfoMessage; } public get schemas() { diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx index 4b7b4dae02d0a..37f564aaa3a18 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -79,7 +79,7 @@ export const VisualizeEditorCommon = ({ /> )} {visInstance?.vis?.type?.isExperimental && } - {visInstance?.vis?.type?.getDeprecationMessage?.(visInstance.vis)} + {visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)} {visInstance && (

diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index 87a1bc20920a4..0d6d0286c5a8f 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/plugin-helpers": "1.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json index 8bbf6274bd15f..8efd2ee432415 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "react": "^16.12.0", "typescript": "4.0.2" } diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index c0d9a03d02c32..4405063e54c06 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/plugin-helpers": "1.0.0", "react": "^16.12.0", "typescript": "4.0.2" diff --git a/x-pack/README.md b/x-pack/README.md index 0449f1fc1bdab..73d8736124843 100644 --- a/x-pack/README.md +++ b/x-pack/README.md @@ -55,7 +55,7 @@ yarn test:mocha For more info, see [the Elastic functional test development guide](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html). -The functional UI tests, the API integration tests, and the SAML API integration tests are all run against a live browser, Kibana, and Elasticsearch install. Each set of tests is specified with a unique config that describes how to start the Elasticsearch server, the Kibana server, and what tests to run against them. The sets of tests that exist today are *functional UI tests* ([specified by this config](test/functional/config.js)), *API integration tests* ([specified by this config](test/api_integration/config.ts)), and *SAML API integration tests* ([specified by this config](test/saml_api_integration/config.ts)). +The functional UI tests, the API integration tests, and the SAML API integration tests are all run against a live browser, Kibana, and Elasticsearch install. Each set of tests is specified with a unique config that describes how to start the Elasticsearch server, the Kibana server, and what tests to run against them. The sets of tests that exist today are *functional UI tests* ([specified by this config](test/functional/config.js)), *API integration tests* ([specified by this config](test/api_integration/config.ts)), and *SAML API integration tests* ([specified by this config](test/security_api_integration/saml.config.ts)). The script runs all sets of tests sequentially like so: * builds Elasticsearch and X-Pack @@ -108,7 +108,7 @@ node scripts/functional_tests --config test/api_integration/config We also have SAML API integration tests which set up Elasticsearch and Kibana with SAML support. Run _only_ API integration tests with SAML enabled like so: ```sh -node scripts/functional_tests --config test/saml_api_integration/config +node scripts/functional_tests --config test/security_api_integration/saml.config ``` #### Running Jest integration tests diff --git a/x-pack/examples/ui_actions_enhanced_examples/README.md b/x-pack/examples/ui_actions_enhanced_examples/README.md index ec049bbd33dec..8096fdbf13cfd 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/README.md +++ b/x-pack/examples/ui_actions_enhanced_examples/README.md @@ -5,7 +5,8 @@ To run this example plugin, use the command `yarn start --run-examples`. ## Drilldown examples -This plugin holds few examples on how to add drilldown types to dashboard. +This plugin holds few examples on how to add drilldown types to dashboard. See +`./public/drilldowns/` folder. To play with drilldowns, open any dashboard, click "Edit" to put it in *edit mode*. Now when opening context menu of dashboard panels you should see "Create drilldown" option. @@ -34,3 +35,74 @@ One can see how middle-click or Ctrl + click behavior could be supported using ### `dashboard_to_discover_drilldown` `dashboard_to_discover_drilldown` shows how a real-world drilldown could look like. + + +## Drilldown Manager examples + +*Drilldown Manager* is a collectio of code and React components that allows you +to add drilldowns to any app. To see examples of how drilldows can be added to +your app, run Kibana with `--run-examples` flag: + +``` +yarn start --run-examples +``` + +Then go to "Developer examples" and "UI Actions Enhanced", where you can see examples +where *Drilldown Manager* is used outside of the Dashboard app: + +![image](https://user-images.githubusercontent.com/9773803/94044547-969a3400-fdce-11ea-826a-cbd0773a4000.png) + +These examples show how you can create your custom UI Actions triggers and add +drilldowns to them, or use an embeddable in your app and add drilldows to it. + + +### Trigger examples + +The `/public/triggers` folder shows how you can create custom triggers for your app. +Triggers are things that trigger some action in UI, like "user click". + +Once you have defined your triggers, you need to register them in your plugin: + +```ts +export class MyPlugin implements Plugin { + public setup(core, { uiActionsEnhanced: uiActions }: SetupDependencies) { + uiActions.registerTrigger(myTrigger); + } +} +``` + +### `app1_hello_world_drilldown` + +`app1_hello_world_drilldown` is a basic example that shows how you can add the most +basic drilldown to your custom trigger. + +### `appx_to_dashboard_drilldown` + +`app1_to_dashboard_drilldown` and `app2_to_dashboard_drilldown` show how the Dashboard +drilldown can be used in other apps, outside of Dashboard. + +Basically you define it: + +```ts +type Trigger = typeof MY_TRIGGER_TRIGGER; +type Context = MyAppClickContext; + +export class App1ToDashboardDrilldown extends AbstractDashboardDrilldown { + public readonly supportedTriggers = () => [MY_TRIGGER] as Trigger[]; + + protected async getURL(config: Config, context: Context): Promise { + return 'https://...'; + } +} +``` + +and then you register it in your plugin: + +```ts +export class MyPlugin implements Plugin { + public setup(core, { uiActionsEnhanced: uiActions }: SetupDependencies) { + const drilldown = new App2ToDashboardDrilldown(/* You can pass in dependencies here. */); + uiActions.registerDrilldown(drilldown); + } +} +``` diff --git a/x-pack/examples/ui_actions_enhanced_examples/kibana.json b/x-pack/examples/ui_actions_enhanced_examples/kibana.json index 1bae09b488a2e..4f5ac8519fe5b 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/kibana.json +++ b/x-pack/examples/ui_actions_enhanced_examples/kibana.json @@ -5,10 +5,21 @@ "configPath": ["ui_actions_enhanced_examples"], "server": false, "ui": true, - "requiredPlugins": ["uiActions","uiActionsEnhanced", "data", "discover"], + "requiredPlugins": [ + "uiActions", + "uiActionsEnhanced", + "data", + "discover", + "dashboard", + "dashboardEnhanced", + "developerExamples" + ], "optionalPlugins": [], "requiredBundles": [ + "dashboardEnhanced", + "embeddable", "kibanaUtils", - "kibanaReact" + "kibanaReact", + "share" ] } diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/components/page/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/components/page/index.tsx new file mode 100644 index 0000000000000..7b3e19ff94f0f --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/components/page/index.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +export interface PageProps { + title?: React.ReactNode; +} + +export const Page: React.FC = ({ title = 'Untitled', children }) => { + return ( + + + + +

{title}

+
+
+
+ + + {children} + + +
+ ); +}; diff --git a/x-pack/test/saml_api_integration/ftr_provider_context.d.ts b/x-pack/examples/ui_actions_enhanced_examples/public/components/section/index.tsx similarity index 56% rename from x-pack/test/saml_api_integration/ftr_provider_context.d.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/components/section/index.tsx index e3add3748f56d..399f44df5d403 100644 --- a/x-pack/test/saml_api_integration/ftr_provider_context.d.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/components/section/index.tsx @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; - -import { services } from './services'; - -export type FtrProviderContext = GenericFtrProviderContext; +export * from './section'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/components/section/section.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/components/section/section.tsx new file mode 100644 index 0000000000000..2f210ad53ef7a --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/components/section/section.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiTitle, EuiSpacer } from '@elastic/eui'; + +export interface Props { + title: React.ReactNode; +} + +export const Section: React.FC = ({ title, children }) => { + return ( +
+ +

{title}

+
+ + {children} +
+ ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/app/app.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/app/app.tsx new file mode 100644 index 0000000000000..33f55a1c35bb4 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/app/app.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiPage } from '@elastic/eui'; +import { Page } from '../../components/page'; +import { DrilldownsManager } from '../drilldowns_manager'; + +export const App: React.FC = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts b/x-pack/examples/ui_actions_enhanced_examples/public/containers/app/index.tsx similarity index 56% rename from x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/containers/app/index.tsx index e3add3748f56d..1460fdfef37e6 100644 --- a/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/app/index.tsx @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; - -import { services } from './services'; - -export type FtrProviderContext = GenericFtrProviderContext; +export * from './app'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_manager/drilldowns_manager.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_manager/drilldowns_manager.tsx new file mode 100644 index 0000000000000..3376e8b2df76e --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_manager/drilldowns_manager.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHorizontalRule } from '@elastic/eui'; +import React from 'react'; +import { Section } from '../../components/section/section'; +import { SampleMlJob, SampleApp1ClickContext } from '../../triggers'; +import { DrilldownsWithoutEmbeddableExample } from '../drilldowns_without_embeddable_example'; +import { DrilldownsWithoutEmbeddableSingleButtonExample } from '../drilldowns_without_embeddable_single_button_example/drilldowns_without_embeddable_single_button_example'; +import { DrilldownsWithEmbeddableExample } from '../drilldowns_with_embeddable_example'; + +export const job: SampleMlJob = { + job_id: '123', + job_type: 'anomaly_detector', + description: 'This is some ML job.', +}; + +export const context: SampleApp1ClickContext = { job }; + +export const DrilldownsManager: React.FC = () => { + return ( +
+
+ + + + + + + + + +
+
+ ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_manager/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_manager/index.tsx new file mode 100644 index 0000000000000..1964b32c2d215 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_manager/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './drilldowns_manager'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/drilldowns_with_embeddable_example.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/drilldowns_with_embeddable_example.tsx new file mode 100644 index 0000000000000..a90147d01e8b6 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/drilldowns_with_embeddable_example.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiText, + EuiSpacer, + EuiContextMenuPanelDescriptor, + EuiButton, + EuiPopover, + EuiContextMenu, + EuiFlyout, + EuiCode, + EuiFlexItem, + EuiFlexGroup, +} from '@elastic/eui'; +import { SampleMlJob, SampleApp1ClickContext } from '../../triggers'; +import { EmbeddableRoot } from '../../../../../../src/plugins/embeddable/public'; +import { ButtonEmbeddable } from '../../embeddables/button_embeddable'; +import { useUiActions } from '../../context'; +import { VALUE_CLICK_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; + +export const job: SampleMlJob = { + job_id: '123', + job_type: 'anomaly_detector', + description: 'This is some ML job.', +}; + +export const context: SampleApp1ClickContext = { job }; + +export const DrilldownsWithEmbeddableExample: React.FC = () => { + const { plugins, managerWithEmbeddable } = useUiActions(); + const embeddable = React.useMemo( + () => + new ButtonEmbeddable( + { id: 'DrilldownsWithEmbeddableExample' }, + { uiActions: plugins.uiActionsEnhanced } + ), + [plugins.uiActionsEnhanced] + ); + const [showManager, setShowManager] = React.useState(false); + const [openPopup, setOpenPopup] = React.useState(false); + const viewRef = React.useRef<'create' | 'manage'>('create'); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + items: [ + { + name: 'Create new view', + icon: 'plusInCircle', + onClick: () => { + setOpenPopup(false); + viewRef.current = 'create'; + setShowManager((x) => !x); + }, + }, + { + name: 'Drilldown list view', + icon: 'list', + onClick: () => { + setOpenPopup(false); + viewRef.current = 'manage'; + setShowManager((x) => !x); + }, + }, + ], + }, + ]; + + const openManagerButton = showManager ? ( + setShowManager(false)}>Close + ) : ( + setOpenPopup((x) => !x)} + > + Open Drilldown Manager + + } + isOpen={openPopup} + closePopover={() => setOpenPopup(false)} + panelPaddingSize="none" + withTitle + anchorPosition="downLeft" + > + + + ); + + return ( + <> + +

With embeddable example

+

+ This example shows how drilldown manager can be added to an embeddable which executes{' '} + VALUE_CLICK_TRIGGER trigger. Below card is an embeddable which executes + VALUE_CLICK_TRIGGER when it is clicked on. +

+
+ + + + + {openManagerButton} + +
+ +
+
+
+ + {showManager && ( + setShowManager(false)} aria-labelledby="Drilldown Manager"> + setShowManager(false)} + viewMode={viewRef.current} + dynamicActionManager={managerWithEmbeddable} + triggers={[VALUE_CLICK_TRIGGER]} + placeContext={{ embeddable }} + /> + + )} + + ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/index.tsx new file mode 100644 index 0000000000000..ca2f7b1060f19 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_with_embeddable_example/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './drilldowns_with_embeddable_example'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/drilldowns_without_embeddable_example.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/drilldowns_without_embeddable_example.tsx new file mode 100644 index 0000000000000..fb22e98e4a6d9 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/drilldowns_without_embeddable_example.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiSpacer, + EuiFlyout, + EuiPopover, + EuiContextMenu, + EuiContextMenuPanelDescriptor, +} from '@elastic/eui'; +import { useUiActions } from '../../context'; +import { SAMPLE_APP1_CLICK_TRIGGER, SampleMlJob, SampleApp1ClickContext } from '../../triggers'; + +export const job: SampleMlJob = { + job_id: '123', + job_type: 'anomaly_detector', + description: 'This is some ML job.', +}; + +export const context: SampleApp1ClickContext = { job }; + +export const DrilldownsWithoutEmbeddableExample: React.FC = () => { + const { plugins, managerWithoutEmbeddable } = useUiActions(); + const [showManager, setShowManager] = React.useState(false); + const [openPopup, setOpenPopup] = React.useState(false); + const viewRef = React.useRef<'create' | 'manage'>('create'); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + items: [ + { + name: 'Create new view', + icon: 'plusInCircle', + onClick: () => { + setOpenPopup(false); + viewRef.current = 'create'; + setShowManager((x) => !x); + }, + }, + { + name: 'Drilldown list view', + icon: 'list', + onClick: () => { + setOpenPopup(false); + viewRef.current = 'manage'; + setShowManager((x) => !x); + }, + }, + ], + }, + ]; + + const openManagerButton = showManager ? ( + setShowManager(false)}>Close + ) : ( + setOpenPopup((x) => !x)} + > + Open Drilldown Manager + + } + isOpen={openPopup} + closePopover={() => setOpenPopup(false)} + panelPaddingSize="none" + withTitle + anchorPosition="downLeft" + > + + + ); + + return ( + <> + +

Without embeddable example (app 1)

+

+ Drilldown Manager can be integrated into any app in Kibana. This example shows + that drilldown manager can be used in an app which does not use embeddables and executes + its custom UI Actions triggers. +

+
+ + + + + {openManagerButton} + + + plugins.uiActionsEnhanced.executeTriggerActions(SAMPLE_APP1_CLICK_TRIGGER, context) + } + > + Execute click action + + + + + {showManager && ( + setShowManager(false)} aria-labelledby="Drilldown Manager"> + setShowManager(false)} + viewMode={viewRef.current} + dynamicActionManager={managerWithoutEmbeddable} + triggers={[SAMPLE_APP1_CLICK_TRIGGER]} + /> + + )} + + ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/index.tsx new file mode 100644 index 0000000000000..0dee7cf367b04 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_example/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './drilldowns_without_embeddable_example'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/drilldowns_without_embeddable_single_button_example.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/drilldowns_without_embeddable_single_button_example.tsx new file mode 100644 index 0000000000000..58d382fdc2a76 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/drilldowns_without_embeddable_single_button_example.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText, EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer, EuiFlyout } from '@elastic/eui'; +import { useUiActions } from '../../context'; +import { sampleApp2ClickContext, SAMPLE_APP2_CLICK_TRIGGER } from '../../triggers'; + +export const DrilldownsWithoutEmbeddableSingleButtonExample: React.FC = () => { + const { plugins, managerWithoutEmbeddableSingleButton } = useUiActions(); + const [showManager, setShowManager] = React.useState(false); + const viewRef = React.useRef<'create' | 'manage'>('create'); + + return ( + <> + +

Without embeddable example, single button (app 2)

+

+ This example is the same as Without embeddable example but it shows that + drilldown manager actions and user created drilldowns can be combined in one menu, this is + useful, for example, for Canvas where clicking on a Canvas element would show the combined + menu of drilldown manager actions and drilldown actions. +

+
+ + + + + + + plugins.uiActionsEnhanced.executeTriggerActions( + SAMPLE_APP2_CLICK_TRIGGER, + sampleApp2ClickContext + ) + } + > + Click this element + + + + + {showManager && ( + setShowManager(false)} aria-labelledby="Drilldown Manager"> + setShowManager(false)} + viewMode={viewRef.current} + dynamicActionManager={managerWithoutEmbeddableSingleButton} + triggers={[SAMPLE_APP2_CLICK_TRIGGER]} + /> + + )} + + ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/index.tsx new file mode 100644 index 0000000000000..74766309dc723 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './drilldowns_without_embeddable_single_button_example'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/context/context.ts b/x-pack/examples/ui_actions_enhanced_examples/public/context/context.ts new file mode 100644 index 0000000000000..2edb29eb5b28a --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/context/context.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createContext, useContext } from 'react'; +import { CoreStart } from 'src/core/public'; +import { UiActionsEnhancedDynamicActionManager } from '../../../../plugins/ui_actions_enhanced/public'; +import { StartDependencies } from '../plugin'; + +export interface UiActionsExampleAppContextValue { + appBasePath: string; + core: CoreStart; + plugins: StartDependencies; + managerWithoutEmbeddable: UiActionsEnhancedDynamicActionManager; + managerWithoutEmbeddableSingleButton: UiActionsEnhancedDynamicActionManager; + managerWithEmbeddable: UiActionsEnhancedDynamicActionManager; +} + +export const context = createContext(null); +export const useUiActions = () => useContext(context)!; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/context/index.ts similarity index 81% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/context/index.ts index b34e61b3b5e76..94b6977050535 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/index.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/context/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ExplorationTitle } from './exploration_title'; +export * from './context'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/app1_hello_world_drilldown.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/app1_hello_world_drilldown.tsx new file mode 100644 index 0000000000000..25de2f5953f31 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/app1_hello_world_drilldown.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../plugins/ui_actions_enhanced/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; +import { SAMPLE_APP1_CLICK_TRIGGER, SampleApp1ClickContext } from '../../triggers'; +import { SerializableState } from '../../../../../../src/plugins/kibana_utils/common'; + +export interface Config extends SerializableState { + name: string; +} + +type Trigger = typeof SAMPLE_APP1_CLICK_TRIGGER; +type Context = SampleApp1ClickContext; + +export type CollectConfigProps = CollectConfigPropsBase; + +export const APP1_HELLO_WORLD_DRILLDOWN = 'APP1_HELLO_WORLD_DRILLDOWN'; + +export class App1HelloWorldDrilldown implements Drilldown { + public readonly id = APP1_HELLO_WORLD_DRILLDOWN; + + public readonly order = 8; + + public readonly getDisplayName = () => 'Hello world (app 1)'; + + public readonly euiIcon = 'cheer'; + + supportedTriggers(): Trigger[] { + return [SAMPLE_APP1_CLICK_TRIGGER]; + } + + private readonly ReactCollectConfig: React.FC = ({ + config, + onConfig, + context, + }) => ( + + onConfig({ ...config, name: event.target.value })} + /> + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + name: '', + }); + + public readonly isConfigValid = (config: Config): config is Config => { + return !!config.name; + }; + + public readonly execute = async (config: Config, context: Context) => { + alert(`Hello, ${config.name}`); + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/index.tsx new file mode 100644 index 0000000000000..a92ba24d3f345 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './app1_hello_world_drilldown'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/app1_to_dashboard_drilldown.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/app1_to_dashboard_drilldown.ts new file mode 100644 index 0000000000000..058b52c78b427 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/app1_to_dashboard_drilldown.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + DashboardEnhancedAbstractDashboardDrilldown as AbstractDashboardDrilldown, + DashboardEnhancedAbstractDashboardDrilldownConfig as Config, +} from '../../../../../plugins/dashboard_enhanced/public'; +import { SAMPLE_APP1_CLICK_TRIGGER, SampleApp1ClickContext } from '../../triggers'; +import { KibanaURL } from '../../../../../../src/plugins/share/public'; + +export const APP1_TO_DASHBOARD_DRILLDOWN = 'APP1_TO_DASHBOARD_DRILLDOWN'; + +type Trigger = typeof SAMPLE_APP1_CLICK_TRIGGER; +type Context = SampleApp1ClickContext; + +export class App1ToDashboardDrilldown extends AbstractDashboardDrilldown { + public readonly id = APP1_TO_DASHBOARD_DRILLDOWN; + + public readonly supportedTriggers = () => [SAMPLE_APP1_CLICK_TRIGGER] as Trigger[]; + + protected async getURL(config: Config, context: Context): Promise { + const path = await this.urlGenerator.createUrl({ + dashboardId: config.dashboardId, + }); + const url = new KibanaURL(path); + + return url; + } +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/index.tsx new file mode 100644 index 0000000000000..4c0c2c221496a --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './app1_to_dashboard_drilldown'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/app2_to_dashboard_drilldown.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/app2_to_dashboard_drilldown.ts new file mode 100644 index 0000000000000..33bf54d4b4cc2 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/app2_to_dashboard_drilldown.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + DashboardEnhancedAbstractDashboardDrilldown as AbstractDashboardDrilldown, + DashboardEnhancedAbstractDashboardDrilldownConfig as Config, +} from '../../../../../plugins/dashboard_enhanced/public'; +import { SAMPLE_APP2_CLICK_TRIGGER, SampleApp2ClickContext } from '../../triggers'; +import { KibanaURL } from '../../../../../../src/plugins/share/public'; + +export const APP2_TO_DASHBOARD_DRILLDOWN = 'APP2_TO_DASHBOARD_DRILLDOWN'; + +type Trigger = typeof SAMPLE_APP2_CLICK_TRIGGER; +type Context = SampleApp2ClickContext; + +export class App2ToDashboardDrilldown extends AbstractDashboardDrilldown { + public readonly id = APP2_TO_DASHBOARD_DRILLDOWN; + + public readonly supportedTriggers = () => [SAMPLE_APP2_CLICK_TRIGGER] as Trigger[]; + + protected async getURL(config: Config, context: Context): Promise { + const path = await this.urlGenerator.createUrl({ + dashboardId: config.dashboardId, + }); + const url = new KibanaURL(path); + + return url; + } +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/index.tsx new file mode 100644 index 0000000000000..ef09061115f43 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './app2_to_dashboard_drilldown'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/README.md similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/README.md diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/index.tsx similarity index 83% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/index.tsx index cac5f0b29dc6e..a7324f5530130 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/index.tsx @@ -6,14 +6,14 @@ import React from 'react'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; -import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../plugins/ui_actions_enhanced/public'; +import { ChartActionContext } from '../../../../../../src/plugins/embeddable/public'; +import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, -} from '../../../../../src/plugins/ui_actions/public'; +} from '../../../../../../src/plugins/ui_actions/public'; export type ActionContext = ChartActionContext; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/README.md b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/README.md similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/README.md rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/README.md diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/index.tsx similarity index 81% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/index.tsx index fa2f0825f9335..24385bd6baa42 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/index.tsx @@ -6,12 +6,12 @@ import React from 'react'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { RangeSelectContext } from '../../../../../src/plugins/embeddable/public'; -import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public'; -import { SELECT_RANGE_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; -import { BaseActionFactoryContext } from '../../../../plugins/ui_actions_enhanced/public/dynamic_actions'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../plugins/ui_actions_enhanced/public'; +import { RangeSelectContext } from '../../../../../../src/plugins/embeddable/public'; +import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public'; +import { SELECT_RANGE_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; +import { BaseActionFactoryContext } from '../../../../../plugins/ui_actions_enhanced/public/dynamic_actions'; export type Config = { name: string; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/collect_config_container.tsx similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/collect_config_container.tsx diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/index.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/index.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/constants.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/constants.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/drilldown.tsx similarity index 88% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/drilldown.tsx index ba8d7f395e738..9cda534a340d6 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/drilldown.tsx @@ -5,15 +5,15 @@ */ import React from 'react'; -import { StartDependencies as Start } from '../plugin'; -import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; -import { StartServicesGetter } from '../../../../../src/plugins/kibana_utils/public'; +import { StartDependencies as Start } from '../../plugin'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { StartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public'; import { ActionContext, Config, CollectConfigProps } from './types'; import { CollectConfigContainer } from './collect_config_container'; import { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../plugins/ui_actions_enhanced/public'; import { txtGoToDiscover } from './i18n'; -import { APPLY_FILTER_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; +import { APPLY_FILTER_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; const isOutputWithIndexPatterns = ( output: unknown diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/i18n.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/i18n.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/index.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/index.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/types.ts similarity index 87% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/types.ts index 692de571e8a00..f0497780430d4 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; -import { ApplyGlobalFilterActionContext } from '../../../../../src/plugins/data/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; +import { ApplyGlobalFilterActionContext } from '../../../../../../src/plugins/data/public'; export type ActionContext = ApplyGlobalFilterActionContext; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable.ts b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable.ts new file mode 100644 index 0000000000000..fcd0c9b94c988 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createElement } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { AdvancedUiActionsStart } from '../../../../../plugins/ui_actions_enhanced/public'; +import { Embeddable, EmbeddableInput } from '../../../../../../src/plugins/embeddable/public'; +import { ButtonEmbeddableComponent } from './button_embeddable_component'; +import { VALUE_CLICK_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; + +export const BUTTON_EMBEDDABLE = 'BUTTON_EMBEDDABLE'; + +export interface ButtonEmbeddableParams { + uiActions: AdvancedUiActionsStart; +} + +export class ButtonEmbeddable extends Embeddable { + type = BUTTON_EMBEDDABLE; + + constructor(input: EmbeddableInput, private readonly params: ButtonEmbeddableParams) { + super(input, {}); + } + + reload() {} + + private el?: HTMLElement; + + public render(el: HTMLElement): void { + super.render(el); + this.el = el; + render( + createElement(ButtonEmbeddableComponent, { + onClick: () => { + this.params.uiActions.getTrigger(VALUE_CLICK_TRIGGER).exec({ + embeddable: this, + data: { + data: [], + }, + }); + }, + }), + el + ); + } + + public destroy() { + super.destroy(); + if (this.el) unmountComponentAtNode(this.el); + } +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable_component.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable_component.tsx new file mode 100644 index 0000000000000..d810870de467b --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable_component.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiCard, EuiFlexItem, EuiIcon } from '@elastic/eui'; + +export interface ButtonEmbeddableComponentProps { + onClick: () => void; +} + +export const ButtonEmbeddableComponent: React.FC = ({ + onClick, +}) => { + return ( + + } + title={`Click me!`} + description={'This embeddable fires "VALUE_CLICK" trigger on click'} + onClick={onClick} + /> + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/index.ts similarity index 77% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/index.ts index c34290528d914..e3bfc9c7da425 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { CollectConfigContainer } from './collect_config_container'; +export * from './button_embeddable'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/mount.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/mount.tsx new file mode 100644 index 0000000000000..b2909c636b528 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/mount.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, AppMountParameters } from 'kibana/public'; +import { StartDependencies, UiActionsEnhancedExamplesStart } from './plugin'; +import { UiActionsExampleAppContextValue, context } from './context'; + +export const mount = ( + coreSetup: CoreSetup +) => async ({ appBasePath, element }: AppMountParameters) => { + const [ + core, + plugins, + { managerWithoutEmbeddable, managerWithoutEmbeddableSingleButton, managerWithEmbeddable }, + ] = await coreSetup.getStartServices(); + const { App } = await import('./containers/app'); + + const deps: UiActionsExampleAppContextValue = { + appBasePath, + core, + plugins, + managerWithoutEmbeddable, + managerWithoutEmbeddableSingleButton, + managerWithEmbeddable, + }; + const reactElement = ( + + + + ); + render(reactElement, element); + return () => unmountComponentAtNode(element); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts index 3f0b64a2ac9ed..c09f64f7b110b 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts @@ -4,44 +4,174 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Plugin, CoreSetup, CoreStart } from '../../../../src/core/public'; +import { createElement as h } from 'react'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { Plugin, CoreSetup, CoreStart, AppNavLinkStatus } from '../../../../src/core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { AdvancedUiActionsSetup, AdvancedUiActionsStart, } from '../../../../x-pack/plugins/ui_actions_enhanced/public'; -import { DashboardHelloWorldDrilldown } from './dashboard_hello_world_drilldown'; -import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown'; +import { DashboardHelloWorldDrilldown } from './drilldowns/dashboard_hello_world_drilldown'; +import { DashboardToDiscoverDrilldown } from './drilldowns/dashboard_to_discover_drilldown'; +import { App1ToDashboardDrilldown } from './drilldowns/app1_to_dashboard_drilldown'; +import { App1HelloWorldDrilldown } from './drilldowns/app1_hello_world_drilldown'; import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; -import { DashboardHelloWorldOnlyRangeSelectDrilldown } from './dashboard_hello_world_only_range_select_drilldown'; +import { DashboardSetup, DashboardStart } from '../../../../src/plugins/dashboard/public'; +import { DashboardHelloWorldOnlyRangeSelectDrilldown } from './drilldowns/dashboard_hello_world_only_range_select_drilldown'; +import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; +import { + sampleApp1ClickTrigger, + sampleApp2ClickTrigger, + SAMPLE_APP2_CLICK_TRIGGER, + SampleApp2ClickContext, + sampleApp2ClickContext, +} from './triggers'; +import { mount } from './mount'; +import { + UiActionsEnhancedMemoryActionStorage, + UiActionsEnhancedDynamicActionManager, +} from '../../../plugins/ui_actions_enhanced/public'; +import { App2ToDashboardDrilldown } from './drilldowns/app2_to_dashboard_drilldown'; export interface SetupDependencies { + dashboard: DashboardSetup; data: DataPublicPluginSetup; + developerExamples: DeveloperExamplesSetup; discover: DiscoverSetup; uiActionsEnhanced: AdvancedUiActionsSetup; } export interface StartDependencies { + dashboard: DashboardStart; data: DataPublicPluginStart; discover: DiscoverStart; uiActionsEnhanced: AdvancedUiActionsStart; } +export interface UiActionsEnhancedExamplesStart { + managerWithoutEmbeddable: UiActionsEnhancedDynamicActionManager; + managerWithoutEmbeddableSingleButton: UiActionsEnhancedDynamicActionManager; + managerWithEmbeddable: UiActionsEnhancedDynamicActionManager; +} + export class UiActionsEnhancedExamplesPlugin - implements Plugin { + implements Plugin { public setup( - core: CoreSetup, - { uiActionsEnhanced: uiActions }: SetupDependencies + core: CoreSetup, + { uiActionsEnhanced: uiActions, developerExamples }: SetupDependencies ) { const start = createStartServicesGetter(core.getStartServices); uiActions.registerDrilldown(new DashboardHelloWorldDrilldown()); uiActions.registerDrilldown(new DashboardHelloWorldOnlyRangeSelectDrilldown()); uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start })); + uiActions.registerDrilldown(new App1HelloWorldDrilldown()); + uiActions.registerDrilldown(new App1ToDashboardDrilldown({ start })); + uiActions.registerDrilldown(new App2ToDashboardDrilldown({ start })); + + uiActions.registerTrigger(sampleApp1ClickTrigger); + uiActions.registerTrigger(sampleApp2ClickTrigger); + + uiActions.addTriggerAction(SAMPLE_APP2_CLICK_TRIGGER, { + id: 'SINGLE_ELEMENT_EXAMPLE_OPEN_FLYOUT_AT_CREATE', + order: 2, + getDisplayName: () => 'Add drilldown', + getIconType: () => 'plusInCircle', + isCompatible: async ({ workpadId, elementId }: SampleApp2ClickContext) => + workpadId === '123' && elementId === '456', + execute: async () => { + const { core: coreStart, plugins: pluginsStart, self } = start(); + const handle = coreStart.overlays.openFlyout( + toMountPoint( + h(pluginsStart.uiActionsEnhanced.FlyoutManageDrilldowns, { + onClose: () => handle.close(), + viewMode: 'create', + dynamicActionManager: self.managerWithoutEmbeddableSingleButton, + triggers: [SAMPLE_APP2_CLICK_TRIGGER], + placeContext: {}, + }) + ), + { + ownFocus: true, + } + ); + }, + }); + uiActions.addTriggerAction(SAMPLE_APP2_CLICK_TRIGGER, { + id: 'SINGLE_ELEMENT_EXAMPLE_OPEN_FLYOUT_AT_MANAGE', + order: 1, + getDisplayName: () => 'Manage drilldowns', + getIconType: () => 'list', + isCompatible: async ({ workpadId, elementId }: SampleApp2ClickContext) => + workpadId === '123' && elementId === '456', + execute: async () => { + const { core: coreStart, plugins: pluginsStart, self } = start(); + const handle = coreStart.overlays.openFlyout( + toMountPoint( + h(pluginsStart.uiActionsEnhanced.FlyoutManageDrilldowns, { + onClose: () => handle.close(), + viewMode: 'manage', + dynamicActionManager: self.managerWithoutEmbeddableSingleButton, + triggers: [SAMPLE_APP2_CLICK_TRIGGER], + placeContext: { sampleApp2ClickContext }, + }) + ), + { + ownFocus: true, + } + ); + }, + }); + + core.application.register({ + id: 'ui_actions_enhanced-explorer', + title: 'UI Actions Enhanced Explorer', + navLinkStatus: AppNavLinkStatus.hidden, + mount: mount(core), + }); + + developerExamples.register({ + appId: 'ui_actions_enhanced-explorer', + title: 'UI Actions Enhanced', + description: 'Examples of how to use drilldowns.', + links: [ + { + label: 'README', + href: + 'https://github.com/elastic/kibana/tree/master/x-pack/examples/ui_actions_enhanced_examples#ui-actions-enhanced-examples', + iconType: 'logoGithub', + size: 's', + target: '_blank', + }, + ], + }); } - public start(core: CoreStart, plugins: StartDependencies) {} + public start(core: CoreStart, plugins: StartDependencies): UiActionsEnhancedExamplesStart { + const managerWithoutEmbeddable = new UiActionsEnhancedDynamicActionManager({ + storage: new UiActionsEnhancedMemoryActionStorage(), + isCompatible: async () => true, + uiActions: plugins.uiActionsEnhanced, + }); + const managerWithoutEmbeddableSingleButton = new UiActionsEnhancedDynamicActionManager({ + storage: new UiActionsEnhancedMemoryActionStorage(), + isCompatible: async () => true, + uiActions: plugins.uiActionsEnhanced, + }); + const managerWithEmbeddable = new UiActionsEnhancedDynamicActionManager({ + storage: new UiActionsEnhancedMemoryActionStorage(), + isCompatible: async () => true, + uiActions: plugins.uiActionsEnhanced, + }); + + return { + managerWithoutEmbeddable, + managerWithoutEmbeddableSingleButton, + managerWithEmbeddable, + }; + } public stop() {} } diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/triggers/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/index.ts new file mode 100644 index 0000000000000..554cb778934cb --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './sample_app1_trigger'; +export * from './sample_app2_trigger'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app1_trigger.ts b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app1_trigger.ts new file mode 100644 index 0000000000000..93a985626c6cd --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app1_trigger.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Trigger } from '../../../../../src/plugins/ui_actions/public'; + +export const SAMPLE_APP1_CLICK_TRIGGER = 'SAMPLE_APP1_CLICK_TRIGGER'; + +export const sampleApp1ClickTrigger: Trigger<'SAMPLE_APP1_CLICK_TRIGGER'> = { + id: SAMPLE_APP1_CLICK_TRIGGER, + title: 'App 1 trigger fired on click', + description: 'Could be a click on a ML job in ML app.', +}; + +declare module '../../../../../src/plugins/ui_actions/public' { + export interface TriggerContextMapping { + [SAMPLE_APP1_CLICK_TRIGGER]: SampleApp1ClickContext; + } +} + +export interface SampleApp1ClickContext { + job: SampleMlJob; +} + +export interface SampleMlJob { + job_id: string; + job_type: 'anomaly_detector'; + description: string; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app2_trigger.ts b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app2_trigger.ts new file mode 100644 index 0000000000000..664c99afc94a5 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app2_trigger.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Trigger } from '../../../../../src/plugins/ui_actions/public'; + +export const SAMPLE_APP2_CLICK_TRIGGER = 'SAMPLE_APP2_CLICK_TRIGGER'; + +export const sampleApp2ClickTrigger: Trigger<'SAMPLE_APP2_CLICK_TRIGGER'> = { + id: SAMPLE_APP2_CLICK_TRIGGER, + title: 'App 2 trigger fired on click', + description: 'Could be a click on an element in Canvas app.', +}; + +declare module '../../../../../src/plugins/ui_actions/public' { + export interface TriggerContextMapping { + [SAMPLE_APP2_CLICK_TRIGGER]: SampleApp2ClickContext; + } +} + +export interface SampleApp2ClickContext { + workpadId: string; + elementId: string; +} + +export const sampleApp2ClickContext: SampleApp2ClickContext = { + workpadId: '123', + elementId: '456', +}; diff --git a/x-pack/package.json b/x-pack/package.json index ffe1a08855888..0222d198d4f91 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -275,7 +275,7 @@ "@babel/runtime": "^7.11.2", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.10.0", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.0", diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts index 41ec4d2a88e9f..9ff1379894a29 100644 --- a/x-pack/plugins/actions/common/types.ts +++ b/x-pack/plugins/actions/common/types.ts @@ -26,11 +26,25 @@ export interface ActionResult { } // the result returned from an action type executor function +const ActionTypeExecutorResultStatusValues = ['ok', 'error'] as const; +type ActionTypeExecutorResultStatus = typeof ActionTypeExecutorResultStatusValues[number]; + export interface ActionTypeExecutorResult { actionId: string; - status: 'ok' | 'error'; + status: ActionTypeExecutorResultStatus; message?: string; serviceMessage?: string; data?: Data; retry?: null | boolean | Date; } + +export function isActionTypeExecutorResult( + result: unknown +): result is ActionTypeExecutorResult { + const unsafeResult = result as ActionTypeExecutorResult; + return ( + unsafeResult && + typeof unsafeResult?.actionId === 'string' && + ActionTypeExecutorResultStatusValues.includes(unsafeResult?.status) + ); +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts index 4c31691280c2c..513ca2cf18e6c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts @@ -37,7 +37,7 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.string(), + savedObjectId: schema.nullable(schema.string()), title: schema.string(), description: schema.nullable(schema.string()), externalId: schema.nullable(schema.string()), diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts index 151f703dcc07e..b6e3a9525dfd4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts @@ -37,7 +37,7 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.string(), + savedObjectId: schema.nullable(schema.string()), title: schema.string(), description: schema.nullable(schema.string()), externalId: schema.nullable(schema.string()), diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 9896d4175954c..0dd70ea36636e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -34,7 +34,7 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.string(), + savedObjectId: schema.nullable(schema.string()), title: schema.string(), description: schema.nullable(schema.string()), comment: schema.nullable(schema.string()), diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index 0566ff19017f4..3948b698fb482 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -42,6 +42,13 @@ describe('renderApp', () => { licensing: { license$: new Observable() }, triggers_actions_ui: { actionTypeRegistry: {}, alertTypeRegistry: {} }, usageCollection: { reportUiStats: () => {} }, + data: { + query: { + timefilter: { + timefilter: { setTime: () => {}, getTime: () => ({}) }, + }, + }, + }, }; const params = { element: document.createElement('div'), diff --git a/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx index c30cef7210a43..1a565ab8708bc 100644 --- a/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { storiesOf } from '@storybook/react'; import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { ErrorCountAlertTrigger } from '.'; import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; import { @@ -13,32 +13,35 @@ import { MockApmPluginContextWrapper, } from '../../../context/ApmPluginContext/MockApmPluginContext'; -storiesOf('app/ErrorCountAlertTrigger', module).add( - 'example', - () => { - const params = { - threshold: 2, - window: '5m', - }; - - return ( +export default { + title: 'app/ErrorCountAlertTrigger', + component: ErrorCountAlertTrigger, + decorators: [ + (Story: React.ComponentClass) => ( -
- undefined} - setAlertProperty={() => undefined} - /> -
+ +
+ +
+
- ); - }, - { - info: { - propTablesExclude: [ErrorCountAlertTrigger, MockApmPluginContextWrapper], - source: false, - }, - } -); + ), + ], +}; + +export function Example() { + const params = { + threshold: 2, + window: '5m', + }; + + return ( + undefined} + setAlertProperty={() => undefined} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/fields.test.tsx b/x-pack/plugins/apm/public/components/alerting/fields.test.tsx index 7ffb46d3dda49..5af05cedf7fa3 100644 --- a/x-pack/plugins/apm/public/components/alerting/fields.test.tsx +++ b/x-pack/plugins/apm/public/components/alerting/fields.test.tsx @@ -9,7 +9,7 @@ import { act, fireEvent, render } from '@testing-library/react'; import { expectTextsInDocument } from '../../utils/testHelpers'; describe('alerting fields', () => { - describe('Service Fiels', () => { + describe('Service Field', () => { it('renders with value', () => { const component = render(); expectTextsInDocument(component, ['foo']); diff --git a/x-pack/plugins/apm/public/components/alerting/fields.tsx b/x-pack/plugins/apm/public/components/alerting/fields.tsx index aac64649546cc..858604d2baa2a 100644 --- a/x-pack/plugins/apm/public/components/alerting/fields.tsx +++ b/x-pack/plugins/apm/public/components/alerting/fields.tsx @@ -43,7 +43,7 @@ export function EnvironmentField({ })} > List', () => { - {/* @ts-expect-error invalid json props */} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index 5183432b4ae0f..f45b4913243ee 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -454,30 +454,38 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` Object { "culprit": "elasticapm.contrib.django.client.capture", "groupId": "a0ce2c8978ef92cdf2ff163ae28576ee", + "handled": true, "latestOccurrenceAt": "2018-01-10T10:06:37.561Z", "message": "About to blow up!", "occurrenceCount": 75, + "type": "AssertionError", }, Object { "culprit": "opbeans.views.oopsie", "groupId": "f3ac95493913cc7a3cfec30a19d2120a", + "handled": true, "latestOccurrenceAt": "2018-01-10T10:06:37.630Z", "message": "AssertionError: ", "occurrenceCount": 75, + "type": "AssertionError", }, Object { "culprit": "opbeans.tasks.update_stats", "groupId": "e90863d04b7a692435305f09bbe8c840", + "handled": true, "latestOccurrenceAt": "2018-01-10T10:06:36.859Z", "message": "AssertionError: Bad luck!", "occurrenceCount": 24, + "type": "AssertionError", }, Object { "culprit": "opbeans.views.customer", "groupId": "8673d8bf7a032e387c101bafbab0d2bc", + "handled": true, "latestOccurrenceAt": "2018-01-10T10:06:13.211Z", "message": "Customer with ID 8517 not found", "occurrenceCount": 15, + "type": "AssertionError", }, ] } @@ -818,7 +826,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:undefined", + "kuery": "error.exception.type:AssertionError", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -826,16 +834,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } } serviceName="opbeans-python" + title="AssertionError" > + title="AssertionError" + > + AssertionError + @@ -1052,7 +1065,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:undefined", + "kuery": "error.exception.type:AssertionError", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1060,16 +1073,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } } serviceName="opbeans-python" + title="AssertionError" > + title="AssertionError" + > + AssertionError + @@ -1286,7 +1304,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:undefined", + "kuery": "error.exception.type:AssertionError", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1294,16 +1312,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } } serviceName="opbeans-python" + title="AssertionError" > + title="AssertionError" + > + AssertionError + @@ -1520,7 +1543,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:undefined", + "kuery": "error.exception.type:AssertionError", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1528,16 +1551,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } } serviceName="opbeans-python" + title="AssertionError" > + title="AssertionError" + > + AssertionError + diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json index 431a6c71b103b..ad49cd048aee3 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json @@ -2,31 +2,39 @@ "items": [ { "message": "About to blow up!", + "type": "AssertionError", "occurrenceCount": 75, "culprit": "elasticapm.contrib.django.client.capture", "groupId": "a0ce2c8978ef92cdf2ff163ae28576ee", - "latestOccurrenceAt": "2018-01-10T10:06:37.561Z" + "latestOccurrenceAt": "2018-01-10T10:06:37.561Z", + "handled": true }, { "message": "AssertionError: ", + "type": "AssertionError", "occurrenceCount": 75, "culprit": "opbeans.views.oopsie", "groupId": "f3ac95493913cc7a3cfec30a19d2120a", - "latestOccurrenceAt": "2018-01-10T10:06:37.630Z" + "latestOccurrenceAt": "2018-01-10T10:06:37.630Z", + "handled": true }, { "message": "AssertionError: Bad luck!", + "type": "AssertionError", "occurrenceCount": 24, "culprit": "opbeans.tasks.update_stats", "groupId": "e90863d04b7a692435305f09bbe8c840", - "latestOccurrenceAt": "2018-01-10T10:06:36.859Z" + "latestOccurrenceAt": "2018-01-10T10:06:36.859Z", + "handled": true }, { "message": "Customer with ID 8517 not found", + "type": "AssertionError", "occurrenceCount": 15, "culprit": "opbeans.views.customer", "groupId": "8673d8bf7a032e387c101bafbab0d2bc", - "latestOccurrenceAt": "2018-01-10T10:06:13.211Z" + "latestOccurrenceAt": "2018-01-10T10:06:13.211Z", + "handled": true } ], "location": { diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 00be0b37a0e82..0a22604837b97 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -53,6 +53,16 @@ exports[`Home component should render services 1`] = ` }, }, "plugins": Object { + "data": Object { + "query": Object { + "timefilter": Object { + "timefilter": Object { + "getTime": [Function], + "setTime": [Function], + }, + }, + }, + }, "ml": Object { "urlGenerator": MlUrlGenerator { "createUrl": [Function], @@ -126,6 +136,16 @@ exports[`Home component should render traces 1`] = ` }, }, "plugins": Object { + "data": Object { + "query": Object { + "timefilter": Object { + "timefilter": Object { + "getTime": [Function], + "setTime": [Function], + }, + }, + }, + }, "ml": Object { "urlGenerator": MlUrlGenerator { "createUrl": [Function], diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx index 4eb24f8c80b9a..242eb721639a2 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -93,10 +93,7 @@ export function PageLoadDistChart({ : EUI_CHARTS_THEME_LIGHT; return ( - + {(!loading || data) && ( + {(!loading || data) && ( + {storyFn()}) - .add( - 'Basic', - () => { - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } - ) - .add( - '50% Good', - () => { - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } - ) - .add( - '100% Bad', - () => { - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } - ) - .add( - '100% Average', - () => { - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } - ); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx deleted file mode 100644 index fcc7b214943ff..0000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import * as React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { CLS_LABEL, FID_LABEL, LCP_LABEL } from './translations'; -import { CoreVitalItem } from './CoreVitalItem'; -import { UXMetrics } from '../UXMetrics'; -import { formatToSec } from '../UXMetrics/KeyUXMetrics'; - -const CoreVitalsThresholds = { - LCP: { good: '2.5s', bad: '4.0s' }, - FID: { good: '100ms', bad: '300ms' }, - CLS: { good: '0.1', bad: '0.25' }, -}; - -interface Props { - data?: UXMetrics | null; - loading: boolean; -} - -export function CoreVitals({ data, loading }: Props) { - const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {}; - - return ( - - - - - - - - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts deleted file mode 100644 index cae1a43733713..0000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const LCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.lcp', { - defaultMessage: 'Largest contentful paint', -}); - -export const FID_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fip', { - defaultMessage: 'First input delay', -}); - -export const CLS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.cls', { - defaultMessage: 'Cumulative layout shift', -}); - -export const FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', { - defaultMessage: 'First contentful paint', -}); - -export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', { - defaultMessage: 'Total blocking time', -}); - -export const NO_OF_LONG_TASK = i18n.translate( - 'xpack.apm.rum.uxMetrics.noOfLongTasks', - { - defaultMessage: 'No. of long tasks', - } -); - -export const LONGEST_LONG_TASK = i18n.translate( - 'xpack.apm.rum.uxMetrics.longestLongTasks', - { - defaultMessage: 'Longest long task duration', - } -); - -export const SUM_LONG_TASKS = i18n.translate( - 'xpack.apm.rum.uxMetrics.sumLongTasks', - { - defaultMessage: 'Total long tasks duration', - } -); - -export const CV_POOR_LABEL = i18n.translate('xpack.apm.rum.coreVitals.poor', { - defaultMessage: 'a poor', -}); - -export const CV_GOOD_LABEL = i18n.translate('xpack.apm.rum.coreVitals.good', { - defaultMessage: 'a good', -}); - -export const CV_AVERAGE_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.average', - { - defaultMessage: 'an average', - } -); - -export const LEGEND_POOR_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.poor', - { - defaultMessage: 'Poor', - } -); - -export const LEGEND_GOOD_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.good', - { - defaultMessage: 'Good', - } -); - -export const LEGEND_NEEDS_IMPROVEMENT_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.needsImprovement', - { - defaultMessage: 'Needs improvement', - } -); - -export const MORE_LABEL = i18n.translate('xpack.apm.rum.coreVitals.more', { - defaultMessage: 'more', -}); - -export const LESS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.less', { - defaultMessage: 'less', -}); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index 45a40712f90fb..88d14a0213a96 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -13,7 +13,6 @@ import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; import { PageLoadDistChart } from '../Charts/PageLoadDistChart'; import { BreakdownItem } from '../../../../../typings/ui_filters'; import { ResetPercentileZoom } from './ResetPercentileZoom'; -import { FULL_HEIGHT } from '../RumDashboard'; export interface PercentileRange { min?: number | null; @@ -72,7 +71,7 @@ export function PageLoadDistribution() { }; return ( -
+
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx index 7492096b93898..621098b6028cb 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx @@ -12,7 +12,6 @@ import { I18LABELS } from '../translations'; import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; import { PageViewsChart } from '../Charts/PageViewsChart'; import { BreakdownItem } from '../../../../../typings/ui_filters'; -import { FULL_HEIGHT } from '../RumDashboard'; export function PageViewsTrend() { const { urlParams, uiFilters } = useUrlParams(); @@ -49,7 +48,7 @@ export function PageViewsTrend() { ); return ( -
+
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/PageLoadAndViews.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/PageLoadAndViews.tsx index cdc52c98de971..0475b9c62b42a 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/PageLoadAndViews.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/PageLoadAndViews.tsx @@ -5,35 +5,23 @@ */ import React from 'react'; -import { EuiPanel, EuiResizableContainer } from '@elastic/eui'; -import { FULL_HEIGHT } from '../RumDashboard'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { PageLoadDistribution } from '../PageLoadDistribution'; import { PageViewsTrend } from '../PageViewsTrend'; -import { useBreakPoints } from '../hooks/useBreakPoints'; export function PageLoadAndViews() { - const { isLarge } = useBreakPoints(); - return ( - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - - - - - - )} - + + + + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/VisitorBreakdowns.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/VisitorBreakdowns.tsx index 87ffacbf56f96..400fbd9991621 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/VisitorBreakdowns.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/VisitorBreakdowns.tsx @@ -5,35 +5,23 @@ */ import React from 'react'; -import { EuiPanel, EuiResizableContainer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { VisitorBreakdown } from '../VisitorBreakdown'; import { VisitorBreakdownMap } from '../VisitorBreakdownMap'; -import { FULL_HEIGHT } from '../RumDashboard'; -import { useBreakPoints } from '../hooks/useBreakPoints'; export function VisitorBreakdownsPanel() { - const { isLarge } = useBreakPoints(); - return ( - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - - - - - - )} - + + + + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx index 0004599b1821b..9f086f41881c4 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx @@ -10,7 +10,6 @@ import { EuiTitle, EuiSpacer, EuiPanel, - EuiResizableContainer, } from '@elastic/eui'; import React from 'react'; import { ClientMetrics } from './ClientMetrics'; @@ -21,10 +20,8 @@ import { PageLoadAndViews } from './Panels/PageLoadAndViews'; import { VisitorBreakdownsPanel } from './Panels/VisitorBreakdowns'; import { useBreakPoints } from './hooks/useBreakPoints'; -export const FULL_HEIGHT = { height: '100%' }; - export function RumDashboard() { - const { isLarge, isSmall } = useBreakPoints(); + const { isSmall } = useBreakPoints(); return ( @@ -45,22 +42,10 @@ export function RumDashboard() { - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - - )} - + + + + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx index 5b0e9709d4fa3..116266541e282 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx @@ -7,16 +7,16 @@ import React from 'react'; import { EuiFlexItem, EuiStat, EuiFlexGroup } from '@elastic/eui'; import numeral from '@elastic/numeral'; -import { UXMetrics } from './index'; import { FCP_LABEL, LONGEST_LONG_TASK, NO_OF_LONG_TASK, SUM_LONG_TASKS, TBT_LABEL, -} from '../CoreVitals/translations'; +} from './translations'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUxQuery } from '../hooks/useUxQuery'; +import { UXMetrics } from '../../../../../../observability/public'; export function formatToSec( value?: number | string, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx index f43be5beece88..da3e8af6ba048 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx @@ -4,36 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React from 'react'; import { - EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, - EuiLink, EuiPanel, - EuiPopover, EuiSpacer, EuiTitle, - EuiText, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { I18LABELS } from '../translations'; -import { CoreVitals } from '../CoreVitals'; import { KeyUXMetrics } from './KeyUXMetrics'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUxQuery } from '../hooks/useUxQuery'; - -export interface UXMetrics { - cls: string; - fid: number; - lcp: number; - tbt: number; - fcp: number; - lcpRanks: number[]; - fidRanks: number[]; - clsRanks: number[]; -} +import { CoreVitals } from '../../../../../../observability/public'; export function UXMetrics() { const uxQuery = useUxQuery(); @@ -53,10 +37,6 @@ export function UXMetrics() { [uxQuery] ); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const closePopover = () => setIsPopoverOpen(false); - return ( @@ -72,39 +52,6 @@ export function UXMetrics() { - -

- {I18LABELS.coreWebVitals} - setIsPopoverOpen(true)} - color={'text'} - iconType={'questionInCircle'} - /> - } - closePopover={closePopover} - > -
- - - - {' '} - {I18LABELS.coreWebVitals} - - -
-
-

-
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts new file mode 100644 index 0000000000000..e6d8f881bee57 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', { + defaultMessage: 'First contentful paint', +}); + +export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', { + defaultMessage: 'Total blocking time', +}); + +export const NO_OF_LONG_TASK = i18n.translate( + 'xpack.apm.rum.uxMetrics.noOfLongTasks', + { + defaultMessage: 'No. of long tasks', + } +); + +export const LONGEST_LONG_TASK = i18n.translate( + 'xpack.apm.rum.uxMetrics.longestLongTasks', + { + defaultMessage: 'Longest long task duration', + } +); + +export const SUM_LONG_TASKS = i18n.translate( + 'xpack.apm.rum.uxMetrics.sumLongTasks', + { + defaultMessage: 'Total long tasks duration', + } +); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index fd118096526d7..afb09db7bd977 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -88,7 +88,7 @@ export const I18LABELS = { pageLoadDurationByRegion: i18n.translate( 'xpack.apm.rum.visitorBreakdownMap.pageLoadDurationByRegion', { - defaultMessage: 'Page load duration by region', + defaultMessage: 'Page load duration by region (avg.)', } ), searchByUrl: i18n.translate('xpack.apm.rum.filters.searchByUrl', { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts new file mode 100644 index 0000000000000..a9f2486a3c288 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + FetchDataParams, + HasDataParams, + UxFetchDataResponse, +} from '../../../../../observability/public/'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; + +export { createCallApmApi } from '../../../services/rest/createCallApmApi'; + +export const fetchUxOverviewDate = async ({ + absoluteTime, + relativeTime, + serviceName, +}: FetchDataParams): Promise => { + const data = await callApmApi({ + pathname: '/api/apm/rum-client/web-core-vitals', + params: { + query: { + start: new Date(absoluteTime.start).toISOString(), + end: new Date(absoluteTime.end).toISOString(), + uiFilters: `{"serviceName":["${serviceName}"]}`, + }, + }, + }); + + return { + coreWebVitals: data, + appLink: `/app/ux?rangeFrom=${relativeTime.start}&rangeTo=${relativeTime.end}`, + }; +}; + +export async function hasRumData({ absoluteTime }: HasDataParams) { + return await callApmApi({ + pathname: '/api/apm/observability_overview/has_rum_data', + params: { + query: { + start: new Date(absoluteTime.start).toISOString(), + end: new Date(absoluteTime.end).toISOString(), + uiFilters: '', + }, + }, + }); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index 55a0bddcc7818..70eb5eaf8e576 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -4,132 +4,92 @@ * you may not use this file except in compliance with the Elastic License. */ -import { storiesOf } from '@storybook/react'; import cytoscape from 'cytoscape'; import { HttpSetup } from 'kibana/public'; -import React from 'react'; +import React, { ComponentType } from 'react'; import { EuiThemeProvider } from '../../../../../../observability/public'; import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; import { MockUrlParamsContextProvider } from '../../../../context/UrlParamsContext/MockUrlParamsContextProvider'; import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; import { CytoscapeContext } from '../Cytoscape'; import { Popover } from './'; -import { ServiceStatsList } from './ServiceStatsList'; +import exampleGroupedConnectionsData from '../__stories__/example_grouped_connections.json'; -storiesOf('app/ServiceMap/Popover', module) - .addDecorator((storyFn) => { +export default { + title: 'app/ServiceMap/Popover', + component: Popover, + decorators: [ + (Story: ComponentType) => { + const httpMock = ({ + get: async () => ({ + avgCpuUsage: 0.32809666568309237, + avgErrorRate: 0.556068173242986, + avgMemoryUsage: 0.5504868173242986, + transactionStats: { + avgRequestsPerMinute: 164.47222031860858, + avgTransactionDuration: 61634.38905590272, + }, + }), + } as unknown) as HttpSetup; + + createCallApmApi(httpMock); + + return ( + + + +
+ +
+
+
+
+ ); + }, + ], +}; + +export function Example() { + return ; +} +Example.decorators = [ + (Story: ComponentType) => { const node = { data: { id: 'example service', 'service.name': 'example service' }, }; - const cy = cytoscape({ elements: [node] }); - const httpMock = ({ - get: async () => ({ - avgCpuUsage: 0.32809666568309237, - avgErrorRate: 0.556068173242986, - avgMemoryUsage: 0.5504868173242986, - transactionStats: { - avgRequestsPerMinute: 164.47222031860858, - avgTransactionDuration: 61634.38905590272, - }, - }), - } as unknown) as HttpSetup; - createCallApmApi(httpMock); + const cy = cytoscape({ elements: [node] }); setTimeout(() => { cy.$id('example service').select(); }, 0); return ( - - - - -
{storyFn()}
-
-
-
-
+ + + ); - }) - .add( - 'example', - () => { - return ; - }, - { - info: { - propTablesExclude: [ - CytoscapeContext.Provider, - EuiThemeProvider, - MockApmPluginContextWrapper, - MockUrlParamsContextProvider, - Popover, - ], - source: false, - }, - } - ); + }, +]; + +export function Externals() { + return ; +} +Externals.decorators = [ + (Story: ComponentType) => { + const node = { + data: exampleGroupedConnectionsData, + }; + const cy = cytoscape({ elements: [node] }); + + setTimeout(() => { + cy.$id(exampleGroupedConnectionsData.id).select(); + }, 0); -storiesOf('app/ServiceMap/Popover/ServiceStatsList', module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'example', - () => ( - - ), - { info: { propTablesExclude: [EuiThemeProvider] } } - ) - .add( - 'loading', - () => ( - - ), - { info: { propTablesExclude: [EuiThemeProvider] } } - ) - .add( - 'some null values', - () => ( - - ), - { info: { propTablesExclude: [EuiThemeProvider] } } - ) - .add( - 'all null values', - () => ( - - ), - { info: { propTablesExclude: [EuiThemeProvider] } } - ); + return ( + + + + ); + }, +]; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx new file mode 100644 index 0000000000000..052f9e9515751 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ComponentType } from 'react'; +import { EuiThemeProvider } from '../../../../../../observability/public'; +import { ServiceStatsList } from './ServiceStatsList'; + +export default { + title: 'app/ServiceMap/Popover/ServiceStatsList', + component: ServiceStatsList, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export function Example() { + return ( + + ); +} + +export function SomeNullValues() { + return ( + + ); +} + +export function AllNullValues() { + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx index 5b50eb953d896..ee334e2ae9567 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx @@ -5,332 +5,315 @@ */ import { EuiCard, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; import cytoscape from 'cytoscape'; -import React from 'react'; +import React, { ComponentType } from 'react'; +import { EuiThemeProvider } from '../../../../../../observability/public'; import { Cytoscape } from '../Cytoscape'; import { iconForNode } from '../icons'; -import { EuiThemeProvider } from '../../../../../../observability/public'; +import { Centerer } from './centerer'; + +export default { + title: 'app/ServiceMap/Cytoscape', + component: Cytoscape, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export function Example() { + const elements: cytoscape.ElementDefinition[] = [ + { + data: { + id: 'opbeans-python', + 'service.name': 'opbeans-python', + 'agent.name': 'python', + }, + }, + { + data: { + id: 'opbeans-node', + 'service.name': 'opbeans-node', + 'agent.name': 'nodejs', + }, + }, + { + data: { + id: 'opbeans-ruby', + 'service.name': 'opbeans-ruby', + 'agent.name': 'ruby', + }, + }, + { data: { source: 'opbeans-python', target: 'opbeans-node' } }, + { + data: { + bidirectional: true, + source: 'opbeans-python', + target: 'opbeans-ruby', + }, + }, + ]; + const serviceName = 'opbeans-python'; -storiesOf('app/ServiceMap/Cytoscape', module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'example', - () => { - const elements: cytoscape.ElementDefinition[] = [ - { - data: { - id: 'opbeans-python', - 'service.name': 'opbeans-python', - 'agent.name': 'python', - }, - }, - { - data: { - id: 'opbeans-node', - 'service.name': 'opbeans-node', - 'agent.name': 'nodejs', - }, - }, - { - data: { - id: 'opbeans-ruby', - 'service.name': 'opbeans-ruby', - 'agent.name': 'ruby', - }, - }, - { data: { source: 'opbeans-python', target: 'opbeans-node' } }, - { - data: { - bidirectional: true, - source: 'opbeans-python', - target: 'opbeans-ruby', - }, - }, - ]; - const height = 300; - const serviceName = 'opbeans-python'; - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } + return ( + + + ); +} -storiesOf('app/ServiceMap/Cytoscape', module).add( - 'node icons', - () => { - const cy = cytoscape(); - const elements = [ - { data: { id: 'default' } }, - { - data: { - id: 'aws', - 'span.type': 'aws', - 'span.subtype': 'servicename', - }, - }, - { data: { id: 'cache', 'span.type': 'cache' } }, - { data: { id: 'database', 'span.type': 'db' } }, - { - data: { - id: 'cassandra', - 'span.type': 'db', - 'span.subtype': 'cassandra', - }, - }, - { - data: { - id: 'elasticsearch', - 'span.type': 'db', - 'span.subtype': 'elasticsearch', - }, - }, - { - data: { - id: 'mongodb', - 'span.type': 'db', - 'span.subtype': 'mongodb', - }, - }, - { - data: { - id: 'mysql', - 'span.type': 'db', - 'span.subtype': 'mysql', - }, - }, - { - data: { - id: 'postgresql', - 'span.type': 'db', - 'span.subtype': 'postgresql', - }, - }, - { - data: { - id: 'redis', - 'span.type': 'db', - 'span.subtype': 'redis', - }, - }, - { data: { id: 'external', 'span.type': 'external' } }, - { data: { id: 'ext', 'span.type': 'ext' } }, - { - data: { - id: 'graphql', - 'span.type': 'external', - 'span.subtype': 'graphql', - }, - }, - { - data: { - id: 'grpc', - 'span.type': 'external', - 'span.subtype': 'grpc', - }, - }, - { - data: { - id: 'websocket', - 'span.type': 'external', - 'span.subtype': 'websocket', - }, - }, - { data: { id: 'messaging', 'span.type': 'messaging' } }, - { - data: { - id: 'jms', - 'span.type': 'messaging', - 'span.subtype': 'jms', - }, - }, - { - data: { - id: 'kafka', - 'span.type': 'messaging', - 'span.subtype': 'kafka', - }, - }, - { data: { id: 'template', 'span.type': 'template' } }, - { - data: { - id: 'handlebars', - 'span.type': 'template', - 'span.subtype': 'handlebars', - }, - }, - { - data: { - id: 'dark', - 'service.name': 'dark service', - 'agent.name': 'dark', - }, - }, - { - data: { - id: 'dotnet', - 'service.name': 'dotnet service', - 'agent.name': 'dotnet', - }, - }, - { - data: { - id: 'dotNet', - 'service.name': 'dotNet service', - 'agent.name': 'dotNet', - }, - }, - { - data: { - id: 'go', - 'service.name': 'go service', - 'agent.name': 'go', - }, - }, - { - data: { - id: 'java', - 'service.name': 'java service', - 'agent.name': 'java', - }, - }, - { - data: { - id: 'RUM (js-base)', - 'service.name': 'RUM service', - 'agent.name': 'js-base', - }, - }, - { - data: { - id: 'RUM (rum-js)', - 'service.name': 'RUM service', - 'agent.name': 'rum-js', - }, - }, - { - data: { - id: 'nodejs', - 'service.name': 'nodejs service', - 'agent.name': 'nodejs', - }, - }, - { - data: { - id: 'php', - 'service.name': 'php service', - 'agent.name': 'php', - }, - }, - { - data: { - id: 'python', - 'service.name': 'python service', - 'agent.name': 'python', - }, - }, - { - data: { - id: 'ruby', - 'service.name': 'ruby service', - 'agent.name': 'ruby', - }, - }, - ]; - cy.add(elements); +export function NodeIcons() { + const cy = cytoscape(); + const elements = [ + { data: { id: 'default' } }, + { + data: { + id: 'aws', + 'span.type': 'aws', + 'span.subtype': 'servicename', + }, + }, + { data: { id: 'cache', 'span.type': 'cache' } }, + { data: { id: 'database', 'span.type': 'db' } }, + { + data: { + id: 'cassandra', + 'span.type': 'db', + 'span.subtype': 'cassandra', + }, + }, + { + data: { + id: 'elasticsearch', + 'span.type': 'db', + 'span.subtype': 'elasticsearch', + }, + }, + { + data: { + id: 'mongodb', + 'span.type': 'db', + 'span.subtype': 'mongodb', + }, + }, + { + data: { + id: 'mysql', + 'span.type': 'db', + 'span.subtype': 'mysql', + }, + }, + { + data: { + id: 'postgresql', + 'span.type': 'db', + 'span.subtype': 'postgresql', + }, + }, + { + data: { + id: 'redis', + 'span.type': 'db', + 'span.subtype': 'redis', + }, + }, + { data: { id: 'external', 'span.type': 'external' } }, + { data: { id: 'ext', 'span.type': 'ext' } }, + { + data: { + id: 'graphql', + 'span.type': 'external', + 'span.subtype': 'graphql', + }, + }, + { + data: { + id: 'grpc', + 'span.type': 'external', + 'span.subtype': 'grpc', + }, + }, + { + data: { + id: 'websocket', + 'span.type': 'external', + 'span.subtype': 'websocket', + }, + }, + { data: { id: 'messaging', 'span.type': 'messaging' } }, + { + data: { + id: 'jms', + 'span.type': 'messaging', + 'span.subtype': 'jms', + }, + }, + { + data: { + id: 'kafka', + 'span.type': 'messaging', + 'span.subtype': 'kafka', + }, + }, + { data: { id: 'template', 'span.type': 'template' } }, + { + data: { + id: 'handlebars', + 'span.type': 'template', + 'span.subtype': 'handlebars', + }, + }, + { + data: { + id: 'dotnet', + 'service.name': 'dotnet service', + 'agent.name': 'dotnet', + }, + }, + { + data: { + id: 'dotNet', + 'service.name': 'dotNet service', + 'agent.name': 'dotNet', + }, + }, + { + data: { + id: 'go', + 'service.name': 'go service', + 'agent.name': 'go', + }, + }, + { + data: { + id: 'java', + 'service.name': 'java service', + 'agent.name': 'java', + }, + }, + { + data: { + id: 'RUM (js-base)', + 'service.name': 'RUM service', + 'agent.name': 'js-base', + }, + }, + { + data: { + id: 'RUM (rum-js)', + 'service.name': 'RUM service', + 'agent.name': 'rum-js', + }, + }, + { + data: { + id: 'nodejs', + 'service.name': 'nodejs service', + 'agent.name': 'nodejs', + }, + }, + { + data: { + id: 'opentelemetry', + 'service.name': 'OpenTelemetry service', + 'agent.name': 'otlp', + }, + }, + { + data: { + id: 'php', + 'service.name': 'php service', + 'agent.name': 'php', + }, + }, + { + data: { + id: 'python', + 'service.name': 'python service', + 'agent.name': 'python', + }, + }, + { + data: { + id: 'ruby', + 'service.name': 'ruby service', + 'agent.name': 'ruby', + }, + }, + ]; + cy.add(elements); - return ( - - {cy.nodes().map((node) => ( - - - agent.name: {node.data('agent.name') || 'undefined'} -
- span.type: {node.data('span.type') || 'undefined'} -
- span.subtype: {node.data('span.subtype') || 'undefined'} - - } - icon={ - {node.data('label')} - } - title={node.data('id')} - /> -
- ))} -
- ); - }, - { - info: { - propTables: false, - source: false, - }, - } -); + return ( + + {cy.nodes().map((node) => ( + + + agent.name: {node.data('agent.name') || 'undefined'} +
+ span.type: {node.data('span.type') || 'undefined'} +
+ span.subtype: {node.data('span.subtype') || 'undefined'} + + } + icon={ + {node.data('label')} + } + title={node.data('id')} + /> +
+ ))} +
+ ); +} -storiesOf('app/ServiceMap/Cytoscape', module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'node severity', - () => { - const elements = [ - { - data: { - id: 'undefined', - 'service.name': 'severity: undefined', - serviceAnomalyStats: { anomalyScore: undefined }, - }, - }, - { - data: { - id: 'warning', - 'service.name': 'severity: warning', - serviceAnomalyStats: { anomalyScore: 0 }, - }, - }, - { - data: { - id: 'minor', - 'service.name': 'severity: minor', - serviceAnomalyStats: { anomalyScore: 40 }, - }, - }, - { - data: { - id: 'major', - 'service.name': 'severity: major', - serviceAnomalyStats: { anomalyScore: 60 }, - }, - }, - { - data: { - id: 'critical', - 'service.name': 'severity: critical', - serviceAnomalyStats: { anomalyScore: 80 }, - }, - }, - ]; - return ; - }, - { - info: { propTables: false, source: false }, - } +export function NodeHealthStatus() { + const elements = [ + { + data: { + id: 'undefined', + 'service.name': 'undefined', + serviceAnomalyStats: { healthStatus: undefined }, + }, + }, + { + data: { + id: 'healthy', + 'service.name': 'healthy', + serviceAnomalyStats: { healthStatus: 'healthy' }, + }, + }, + { + data: { + id: 'warning', + 'service.name': 'warning', + serviceAnomalyStats: { healthStatus: 'warning' }, + }, + }, + { + data: { + id: 'critical', + 'service.name': 'critical', + serviceAnomalyStats: { healthStatus: 'critical' }, + }, + }, + ]; + return ( + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx deleted file mode 100644 index d8dcc71f5051d..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/CytoscapeExampleData.stories.tsx +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiButton, - EuiCodeEditor, - EuiFieldNumber, - EuiFilePicker, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiSpacer, - EuiToolTip, -} from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import React, { useEffect, useState } from 'react'; -import { EuiThemeProvider } from '../../../../../../observability/public'; -import { Cytoscape } from '../Cytoscape'; -import exampleResponseHipsterStore from './example_response_hipster_store.json'; -import exampleResponseOpbeansBeats from './example_response_opbeans_beats.json'; -import exampleResponseTodo from './example_response_todo.json'; -import exampleResponseOneDomainManyIPs from './example_response_one_domain_many_ips.json'; -import { generateServiceMapElements } from './generate_service_map_elements'; - -const STORYBOOK_PATH = 'app/ServiceMap/Cytoscape/Example data'; - -const SESSION_STORAGE_KEY = `${STORYBOOK_PATH}/pre-loaded map`; -function getSessionJson() { - return window.sessionStorage.getItem(SESSION_STORAGE_KEY); -} -function setSessionJson(json: string) { - window.sessionStorage.setItem(SESSION_STORAGE_KEY, json); -} - -const getCytoscapeHeight = () => window.innerHeight - 300; - -storiesOf(STORYBOOK_PATH, module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'Generate map', - () => { - const [size, setSize] = useState(10); - const [json, setJson] = useState(''); - const [elements, setElements] = useState( - generateServiceMapElements({ size, hasAnomalies: true }) - ); - return ( -
- - - { - setElements( - generateServiceMapElements({ size, hasAnomalies: true }) - ); - setJson(''); - }} - > - Generate service map - - - - - setSize(e.target.valueAsNumber)} - /> - - - - { - setJson(JSON.stringify({ elements }, null, 2)); - }} - > - Get JSON - - - - - - - {json && ( - - )} -
- ); - }, - { - info: { propTables: false, source: false }, - } - ); - -storiesOf(STORYBOOK_PATH, module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'Map from JSON', - () => { - const [json, setJson] = useState( - getSessionJson() || JSON.stringify(exampleResponseTodo, null, 2) - ); - const [error, setError] = useState(); - - const [elements, setElements] = useState([]); - useEffect(() => { - try { - setElements(JSON.parse(json).elements); - } catch (e) { - setError(e.message); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( -
- - - - - { - setJson(value); - }} - /> - - - - { - const item = event?.item(0); - - if (item) { - const f = new FileReader(); - f.onload = (onloadEvent) => { - const result = onloadEvent?.target?.result; - if (typeof result === 'string') { - setJson(result); - } - }; - f.readAsText(item); - } - }} - /> - - { - try { - setElements(JSON.parse(json).elements); - setSessionJson(json); - setError(undefined); - } catch (e) { - setError(e.message); - } - }} - > - Render JSON - - - - - -
- ); - }, - { - info: { - propTables: false, - source: false, - text: ` - Enter JSON map data into the text box or upload a file and click "Render JSON" to see the results. You can enable a download button on the service map by putting - - \`\`\` - sessionStorage.setItem('apm_debug', 'true') - \`\`\` - - into the JavaScript console and reloading the page.`, - }, - } - ); - -storiesOf(STORYBOOK_PATH, module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'Todo app', - () => { - return ( -
- -
- ); - }, - { - info: { propTables: false, source: false }, - } - ); - -storiesOf(STORYBOOK_PATH, module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'Opbeans + beats', - () => { - return ( -
- -
- ); - }, - { - info: { propTables: false, source: false }, - } - ); - -storiesOf(STORYBOOK_PATH, module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'Hipster store', - () => { - return ( -
- -
- ); - }, - { - info: { propTables: false, source: false }, - } - ); - -storiesOf(STORYBOOK_PATH, module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'Node resolves one domain name to many IPs', - () => { - return ( -
- -
- ); - }, - { - info: { propTables: false, source: false }, - } - ); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.tsx new file mode 100644 index 0000000000000..16dad1e03b5a6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext, useEffect } from 'react'; +import { CytoscapeContext } from '../Cytoscape'; + +// Component to center map on load +export function Centerer() { + const cy = useContext(CytoscapeContext); + + useEffect(() => { + if (cy) { + cy.one('layoutstop', (event) => { + event.cy.animate({ + duration: 50, + center: { eles: '' }, + fit: { eles: '', padding: 50 }, + }); + }); + } + }, [cy]); + + return null; +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx new file mode 100644 index 0000000000000..0673735ba0adb --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiButton, + EuiCodeEditor, + EuiFieldNumber, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiSpacer, + EuiToolTip, +} from '@elastic/eui'; +import React, { ComponentType, useEffect, useState } from 'react'; +import { EuiThemeProvider } from '../../../../../../observability/public'; +import { Cytoscape } from '../Cytoscape'; +import { Centerer } from './centerer'; +import exampleResponseHipsterStore from './example_response_hipster_store.json'; +import exampleResponseOpbeansBeats from './example_response_opbeans_beats.json'; +import exampleResponseTodo from './example_response_todo.json'; +import { generateServiceMapElements } from './generate_service_map_elements'; + +const STORYBOOK_PATH = 'app/ServiceMap/Cytoscape/Example data'; + +const SESSION_STORAGE_KEY = `${STORYBOOK_PATH}/pre-loaded map`; +function getSessionJson() { + return window.sessionStorage.getItem(SESSION_STORAGE_KEY); +} +function setSessionJson(json: string) { + window.sessionStorage.setItem(SESSION_STORAGE_KEY, json); +} + +function getHeight() { + return window.innerHeight - 300; +} + +export default { + title: 'app/ServiceMap/Cytoscape/Example data', + component: Cytoscape, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export function GenerateMap() { + const [size, setSize] = useState(10); + const [json, setJson] = useState(''); + const [elements, setElements] = useState( + generateServiceMapElements({ size, hasAnomalies: true }) + ); + return ( +
+ + + { + setElements( + generateServiceMapElements({ size, hasAnomalies: true }) + ); + setJson(''); + }} + > + Generate service map + + + + + setSize(e.target.valueAsNumber)} + /> + + + + { + setJson(JSON.stringify({ elements }, null, 2)); + }} + > + Get JSON + + + + + + + + + {json && ( + + )} +
+ ); +} + +export function MapFromJSON() { + const [json, setJson] = useState( + getSessionJson() || JSON.stringify(exampleResponseTodo, null, 2) + ); + const [error, setError] = useState(); + + const [elements, setElements] = useState([]); + useEffect(() => { + try { + setElements(JSON.parse(json).elements); + } catch (e) { + setError(e.message); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ + + + + + + { + setJson(value); + }} + /> + + + + { + const item = event?.item(0); + + if (item) { + const f = new FileReader(); + f.onload = (onloadEvent) => { + const result = onloadEvent?.target?.result; + if (typeof result === 'string') { + setJson(result); + } + }; + f.readAsText(item); + } + }} + /> + + { + try { + setElements(JSON.parse(json).elements); + setSessionJson(json); + setError(undefined); + } catch (e) { + setError(e.message); + } + }} + > + Render JSON + + + + + +
+ ); +} + +export function TodoApp() { + return ( +
+ + + +
+ ); +} + +export function OpbeansAndBeats() { + return ( +
+ + + +
+ ); +} + +export function HipsterStore() { + return ( +
+ + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json new file mode 100644 index 0000000000000..55686f99f388a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json @@ -0,0 +1,875 @@ +{ + "id": "resourceGroup{elastic-co-frontend}", + "span.type": "external", + "label": "124 resources", + "groupedConnections": [ + { + "label": "813-mam-392.mktoresp.com:443", + "span.subtype": "http", + "span.destination.service.resource": "813-mam-392.mktoresp.com:443", + "span.type": "external", + "id": ">813-mam-392.mktoresp.com:443" + }, + { + "label": "813-mam-392.mktoutil.com:443", + "span.subtype": "http", + "span.destination.service.resource": "813-mam-392.mktoutil.com:443", + "span.type": "external", + "id": ">813-mam-392.mktoutil.com:443" + }, + { + "label": "8d1f.com:443", + "span.subtype": "link", + "span.destination.service.resource": "8d1f.com:443", + "span.type": "resource", + "id": ">8d1f.com:443" + }, + { + "label": "a.ssl-checking.com:443", + "span.subtype": "xmlhttprequest", + "span.destination.service.resource": "a.ssl-checking.com:443", + "span.type": "resource", + "id": ">a.ssl-checking.com:443" + }, + { + "label": "a18132920325.cdn.optimizely.com:443", + "span.subtype": "iframe", + "span.destination.service.resource": "a18132920325.cdn.optimizely.com:443", + "span.type": "resource", + "id": ">a18132920325.cdn.optimizely.com:443" + }, + { + "label": "api.contentstack.io:443", + "span.subtype": "img", + "span.destination.service.resource": "api.contentstack.io:443", + "span.type": "resource", + "id": ">api.contentstack.io:443" + }, + { + "label": "assets.website-files.com:443", + "span.subtype": "css", + "span.destination.service.resource": "assets.website-files.com:443", + "span.type": "resource", + "id": ">assets.website-files.com:443" + }, + { + "label": "bat.bing.com:443", + "span.subtype": "img", + "span.destination.service.resource": "bat.bing.com:443", + "span.type": "resource", + "id": ">bat.bing.com:443" + }, + { + "label": "bid.g.doubleclick.net:443", + "span.subtype": "iframe", + "span.destination.service.resource": "bid.g.doubleclick.net:443", + "span.type": "resource", + "id": ">bid.g.doubleclick.net:443" + }, + { + "label": "cdn.iubenda.com:443", + "span.subtype": "iframe", + "span.destination.service.resource": "cdn.iubenda.com:443", + "span.type": "resource", + "id": ">cdn.iubenda.com:443" + }, + { + "label": "cdn.loom.com:443", + "span.subtype": "css", + "span.destination.service.resource": "cdn.loom.com:443", + "span.type": "resource", + "id": ">cdn.loom.com:443" + }, + { + "label": "cdn.optimizely.com:443", + "span.subtype": "script", + "span.destination.service.resource": "cdn.optimizely.com:443", + "span.type": "resource", + "id": ">cdn.optimizely.com:443" + }, + { + "label": "cdncache-a.akamaihd.net:443", + "span.subtype": "http", + "span.destination.service.resource": "cdncache-a.akamaihd.net:443", + "span.type": "external", + "id": ">cdncache-a.akamaihd.net:443" + }, + { + "label": "cloud.githubusercontent.com:443", + "span.subtype": "img", + "span.destination.service.resource": "cloud.githubusercontent.com:443", + "span.type": "resource", + "id": ">cloud.githubusercontent.com:443" + }, + { + "label": "config.privoxy.org:443", + "span.subtype": "script", + "span.destination.service.resource": "config.privoxy.org:443", + "span.type": "resource", + "id": ">config.privoxy.org:443" + }, + { + "label": "connect.facebook.net:443", + "span.subtype": "script", + "span.destination.service.resource": "connect.facebook.net:443", + "span.type": "resource", + "id": ">connect.facebook.net:443" + }, + { + "label": "dpx.airpr.com:443", + "span.subtype": "img", + "span.destination.service.resource": "dpx.airpr.com:443", + "span.type": "resource", + "id": ">dpx.airpr.com:443" + }, + { + "label": "errors.client.optimizely.com:443", + "span.subtype": "http", + "span.destination.service.resource": "errors.client.optimizely.com:443", + "span.type": "external", + "id": ">errors.client.optimizely.com:443" + }, + { + "label": "fonts.googleapis.com:443", + "span.subtype": "css", + "span.destination.service.resource": "fonts.googleapis.com:443", + "span.type": "resource", + "id": ">fonts.googleapis.com:443" + }, + { + "label": "fonts.gstatic.com:443", + "span.subtype": "css", + "span.destination.service.resource": "fonts.gstatic.com:443", + "span.type": "resource", + "id": ">fonts.gstatic.com:443" + }, + { + "label": "ga.clearbit.com:443", + "span.subtype": "script", + "span.destination.service.resource": "ga.clearbit.com:443", + "span.type": "resource", + "id": ">ga.clearbit.com:443" + }, + { + "label": "gc.kis.v2.scr.kaspersky-labs.com:443", + "span.subtype": "script", + "span.destination.service.resource": "gc.kis.v2.scr.kaspersky-labs.com:443", + "span.type": "resource", + "id": ">gc.kis.v2.scr.kaspersky-labs.com:443" + }, + { + "label": "googleads.g.doubleclick.net:443", + "span.subtype": "script", + "span.destination.service.resource": "googleads.g.doubleclick.net:443", + "span.type": "resource", + "id": ">googleads.g.doubleclick.net:443" + }, + { + "label": "hits-i.iubenda.com:443", + "span.subtype": "http", + "span.destination.service.resource": "hits-i.iubenda.com:443", + "span.type": "external", + "id": ">hits-i.iubenda.com:443" + }, + { + "label": "host-1r8dhi.api.swiftype.com:443", + "span.subtype": "http", + "span.destination.service.resource": "host-1r8dhi.api.swiftype.com:443", + "span.type": "external", + "id": ">host-1r8dhi.api.swiftype.com:443" + }, + { + "label": "host-nm1h2z.api.swiftype.com:443", + "span.subtype": "http", + "span.destination.service.resource": "host-nm1h2z.api.swiftype.com:443", + "span.type": "external", + "id": ">host-nm1h2z.api.swiftype.com:443" + }, + { + "label": "images.contentstack.io:443", + "span.subtype": "css", + "span.destination.service.resource": "images.contentstack.io:443", + "span.type": "resource", + "id": ">images.contentstack.io:443" + }, + { + "label": "info.elastic.co:443", + "span.subtype": "iframe", + "span.destination.service.resource": "info.elastic.co:443", + "span.type": "resource", + "id": ">info.elastic.co:443" + }, + { + "label": "info.elastic.co:80", + "span.subtype": "img", + "span.destination.service.resource": "info.elastic.co:80", + "span.type": "resource", + "id": ">info.elastic.co:80" + }, + { + "label": "js.clearbit.com:443", + "span.subtype": "script", + "span.destination.service.resource": "js.clearbit.com:443", + "span.type": "resource", + "id": ">js.clearbit.com:443" + }, + { + "label": "lh4.googleusercontent.com:443", + "span.subtype": "img", + "span.destination.service.resource": "lh4.googleusercontent.com:443", + "span.type": "resource", + "id": ">lh4.googleusercontent.com:443" + }, + { + "label": "lh6.googleusercontent.com:443", + "span.subtype": "img", + "span.destination.service.resource": "lh6.googleusercontent.com:443", + "span.type": "resource", + "id": ">lh6.googleusercontent.com:443" + }, + { + "label": "logx.optimizely.com:443", + "span.subtype": "http", + "span.destination.service.resource": "logx.optimizely.com:443", + "span.type": "external", + "id": ">logx.optimizely.com:443" + }, + { + "label": "m98.prod2016.com:443", + "span.subtype": "http", + "span.destination.service.resource": "m98.prod2016.com:443", + "span.type": "external", + "id": ">m98.prod2016.com:443" + }, + { + "label": "maps.googleapis.com:443", + "span.subtype": "img", + "span.destination.service.resource": "maps.googleapis.com:443", + "span.type": "resource", + "id": ">maps.googleapis.com:443" + }, + { + "label": "maps.gstatic.com:443", + "span.subtype": "css", + "span.destination.service.resource": "maps.gstatic.com:443", + "span.type": "resource", + "id": ">maps.gstatic.com:443" + }, + { + "label": "munchkin.marketo.net:443", + "span.subtype": "script", + "span.destination.service.resource": "munchkin.marketo.net:443", + "span.type": "resource", + "id": ">munchkin.marketo.net:443" + }, + { + "label": "negbar.ad-blocker.org:443", + "span.subtype": "script", + "span.destination.service.resource": "negbar.ad-blocker.org:443", + "span.type": "resource", + "id": ">negbar.ad-blocker.org:443" + }, + { + "label": "p.typekit.net:443", + "span.subtype": "css", + "span.destination.service.resource": "p.typekit.net:443", + "span.type": "resource", + "id": ">p.typekit.net:443" + }, + { + "label": "platform.twitter.com:443", + "span.subtype": "iframe", + "span.destination.service.resource": "platform.twitter.com:443", + "span.type": "resource", + "id": ">platform.twitter.com:443" + }, + { + "label": "play.vidyard.com:443", + "span.subtype": "iframe", + "span.destination.service.resource": "play.vidyard.com:443", + "span.type": "resource", + "id": ">play.vidyard.com:443" + }, + { + "label": "px.ads.linkedin.com:443", + "span.subtype": "img", + "span.destination.service.resource": "px.ads.linkedin.com:443", + "span.type": "resource", + "id": ">px.ads.linkedin.com:443" + }, + { + "label": "px.airpr.com:443", + "span.subtype": "script", + "span.destination.service.resource": "px.airpr.com:443", + "span.type": "resource", + "id": ">px.airpr.com:443" + }, + { + "label": "q.quora.com:443", + "span.subtype": "img", + "span.destination.service.resource": "q.quora.com:443", + "span.type": "resource", + "id": ">q.quora.com:443" + }, + { + "label": "risk.clearbit.com:443", + "span.subtype": "http", + "span.destination.service.resource": "risk.clearbit.com:443", + "span.type": "external", + "id": ">risk.clearbit.com:443" + }, + { + "label": "rtp-static.marketo.com:443", + "span.subtype": "http", + "span.destination.service.resource": "rtp-static.marketo.com:443", + "span.type": "external", + "id": ">rtp-static.marketo.com:443" + }, + { + "label": "rum.optimizely.com:443", + "span.subtype": "http", + "span.destination.service.resource": "rum.optimizely.com:443", + "span.type": "external", + "id": ">rum.optimizely.com:443" + }, + { + "label": "s3-us-west-1.amazonaws.com:443", + "span.subtype": "script", + "span.destination.service.resource": "s3-us-west-1.amazonaws.com:443", + "span.type": "resource", + "id": ">s3-us-west-1.amazonaws.com:443" + }, + { + "label": "sjrtp2-cdn.marketo.com:443", + "span.subtype": "script", + "span.destination.service.resource": "sjrtp2-cdn.marketo.com:443", + "span.type": "resource", + "id": ">sjrtp2-cdn.marketo.com:443" + }, + { + "label": "sjrtp2.marketo.com:443", + "span.subtype": "http", + "span.destination.service.resource": "sjrtp2.marketo.com:443", + "span.type": "external", + "id": ">sjrtp2.marketo.com:443" + }, + { + "label": "snap.licdn.com:443", + "span.subtype": "script", + "span.destination.service.resource": "snap.licdn.com:443", + "span.type": "resource", + "id": ">snap.licdn.com:443" + }, + { + "label": "speakerdeck.com:443", + "span.subtype": "iframe", + "span.destination.service.resource": "speakerdeck.com:443", + "span.type": "resource", + "id": ">speakerdeck.com:443" + }, + { + "label": "stag-static-www.elastic.co:443", + "span.subtype": "img", + "span.destination.service.resource": "stag-static-www.elastic.co:443", + "span.type": "resource", + "id": ">stag-static-www.elastic.co:443" + }, + { + "label": "static-www.elastic.co:443", + "span.subtype": "css", + "span.destination.service.resource": "static-www.elastic.co:443", + "span.type": "resource", + "id": ">static-www.elastic.co:443" + }, + { + "label": "stats.g.doubleclick.net:443", + "span.subtype": "http", + "span.destination.service.resource": "stats.g.doubleclick.net:443", + "span.type": "external", + "id": ">stats.g.doubleclick.net:443" + }, + { + "label": "translate.google.com:443", + "span.subtype": "img", + "span.destination.service.resource": "translate.google.com:443", + "span.type": "resource", + "id": ">translate.google.com:443" + }, + { + "label": "translate.googleapis.com:443", + "span.subtype": "link", + "span.destination.service.resource": "translate.googleapis.com:443", + "span.type": "resource", + "id": ">translate.googleapis.com:443" + }, + { + "label": "use.typekit.net:443", + "span.subtype": "link", + "span.destination.service.resource": "use.typekit.net:443", + "span.type": "resource", + "id": ">use.typekit.net:443" + }, + { + "label": "www.elastic.co:443", + "span.subtype": "browser-timing", + "span.destination.service.resource": "www.elastic.co:443", + "span.type": "external", + "id": ">www.elastic.co:443" + }, + { + "label": "www.facebook.com:443", + "span.subtype": "beacon", + "span.destination.service.resource": "www.facebook.com:443", + "span.type": "resource", + "id": ">www.facebook.com:443" + }, + { + "label": "www.google-analytics.com:443", + "span.subtype": "beacon", + "span.destination.service.resource": "www.google-analytics.com:443", + "span.type": "external", + "id": ">www.google-analytics.com:443" + }, + { + "label": "www.google.ae:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.ae:443", + "span.type": "resource", + "id": ">www.google.ae:443" + }, + { + "label": "www.google.al:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.al:443", + "span.type": "resource", + "id": ">www.google.al:443" + }, + { + "label": "www.google.at:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.at:443", + "span.type": "resource", + "id": ">www.google.at:443" + }, + { + "label": "www.google.ba:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.ba:443", + "span.type": "resource", + "id": ">www.google.ba:443" + }, + { + "label": "www.google.be:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.be:443", + "span.type": "resource", + "id": ">www.google.be:443" + }, + { + "label": "www.google.bg:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.bg:443", + "span.type": "resource", + "id": ">www.google.bg:443" + }, + { + "label": "www.google.by:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.by:443", + "span.type": "resource", + "id": ">www.google.by:443" + }, + { + "label": "www.google.ca:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.ca:443", + "span.type": "resource", + "id": ">www.google.ca:443" + }, + { + "label": "www.google.ch:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.ch:443", + "span.type": "resource", + "id": ">www.google.ch:443" + }, + { + "label": "www.google.cl:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.cl:443", + "span.type": "resource", + "id": ">www.google.cl:443" + }, + { + "label": "www.google.co.cr:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.cr:443", + "span.type": "resource", + "id": ">www.google.co.cr:443" + }, + { + "label": "www.google.co.id:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.id:443", + "span.type": "resource", + "id": ">www.google.co.id:443" + }, + { + "label": "www.google.co.il:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.il:443", + "span.type": "resource", + "id": ">www.google.co.il:443" + }, + { + "label": "www.google.co.in:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.in:443", + "span.type": "resource", + "id": ">www.google.co.in:443" + }, + { + "label": "www.google.co.jp:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.jp:443", + "span.type": "resource", + "id": ">www.google.co.jp:443" + }, + { + "label": "www.google.co.kr:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.kr:443", + "span.type": "resource", + "id": ">www.google.co.kr:443" + }, + { + "label": "www.google.co.ma:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.ma:443", + "span.type": "resource", + "id": ">www.google.co.ma:443" + }, + { + "label": "www.google.co.uk:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.uk:443", + "span.type": "resource", + "id": ">www.google.co.uk:443" + }, + { + "label": "www.google.co.za:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.co.za:443", + "span.type": "resource", + "id": ">www.google.co.za:443" + }, + { + "label": "www.google.com.ar:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.ar:443", + "span.type": "resource", + "id": ">www.google.com.ar:443" + }, + { + "label": "www.google.com.au:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.au:443", + "span.type": "resource", + "id": ">www.google.com.au:443" + }, + { + "label": "www.google.com.bo:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.bo:443", + "span.type": "resource", + "id": ">www.google.com.bo:443" + }, + { + "label": "www.google.com.br:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.br:443", + "span.type": "resource", + "id": ">www.google.com.br:443" + }, + { + "label": "www.google.com.co:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.co:443", + "span.type": "resource", + "id": ">www.google.com.co:443" + }, + { + "label": "www.google.com.eg:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.eg:443", + "span.type": "resource", + "id": ">www.google.com.eg:443" + }, + { + "label": "www.google.com.mm:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.mm:443", + "span.type": "resource", + "id": ">www.google.com.mm:443" + }, + { + "label": "www.google.com.mx:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.mx:443", + "span.type": "resource", + "id": ">www.google.com.mx:443" + }, + { + "label": "www.google.com.my:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.my:443", + "span.type": "resource", + "id": ">www.google.com.my:443" + }, + { + "label": "www.google.com.pe:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.pe:443", + "span.type": "resource", + "id": ">www.google.com.pe:443" + }, + { + "label": "www.google.com.sa:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.sa:443", + "span.type": "resource", + "id": ">www.google.com.sa:443" + }, + { + "label": "www.google.com.sg:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.sg:443", + "span.type": "resource", + "id": ">www.google.com.sg:443" + }, + { + "label": "www.google.com.tr:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.tr:443", + "span.type": "resource", + "id": ">www.google.com.tr:443" + }, + { + "label": "www.google.com.ua:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.ua:443", + "span.type": "resource", + "id": ">www.google.com.ua:443" + }, + { + "label": "www.google.com.uy:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.com.uy:443", + "span.type": "resource", + "id": ">www.google.com.uy:443" + }, + { + "label": "www.google.com:443", + "span.subtype": "beacon", + "span.destination.service.resource": "www.google.com:443", + "span.type": "resource", + "id": ">www.google.com:443" + }, + { + "label": "www.google.cz:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.cz:443", + "span.type": "resource", + "id": ">www.google.cz:443" + }, + { + "label": "www.google.de:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.de:443", + "span.type": "resource", + "id": ">www.google.de:443" + }, + { + "label": "www.google.dk:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.dk:443", + "span.type": "resource", + "id": ">www.google.dk:443" + }, + { + "label": "www.google.es:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.es:443", + "span.type": "resource", + "id": ">www.google.es:443" + }, + { + "label": "www.google.fr:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.fr:443", + "span.type": "resource", + "id": ">www.google.fr:443" + }, + { + "label": "www.google.gr:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.gr:443", + "span.type": "resource", + "id": ">www.google.gr:443" + }, + { + "label": "www.google.hu:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.hu:443", + "span.type": "resource", + "id": ">www.google.hu:443" + }, + { + "label": "www.google.is:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.is:443", + "span.type": "resource", + "id": ">www.google.is:443" + }, + { + "label": "www.google.it:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.it:443", + "span.type": "resource", + "id": ">www.google.it:443" + }, + { + "label": "www.google.lk:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.lk:443", + "span.type": "resource", + "id": ">www.google.lk:443" + }, + { + "label": "www.google.md:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.md:443", + "span.type": "resource", + "id": ">www.google.md:443" + }, + { + "label": "www.google.mk:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.mk:443", + "span.type": "resource", + "id": ">www.google.mk:443" + }, + { + "label": "www.google.nl:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.nl:443", + "span.type": "resource", + "id": ">www.google.nl:443" + }, + { + "label": "www.google.no:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.no:443", + "span.type": "resource", + "id": ">www.google.no:443" + }, + { + "label": "www.google.pl:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.pl:443", + "span.type": "resource", + "id": ">www.google.pl:443" + }, + { + "label": "www.google.pt:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.pt:443", + "span.type": "resource", + "id": ">www.google.pt:443" + }, + { + "label": "www.google.ro:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.ro:443", + "span.type": "resource", + "id": ">www.google.ro:443" + }, + { + "label": "www.google.rs:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.rs:443", + "span.type": "resource", + "id": ">www.google.rs:443" + }, + { + "label": "www.google.ru:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.ru:443", + "span.type": "resource", + "id": ">www.google.ru:443" + }, + { + "label": "www.google.se:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.se:443", + "span.type": "resource", + "id": ">www.google.se:443" + }, + { + "label": "www.google.tn:443", + "span.subtype": "img", + "span.destination.service.resource": "www.google.tn:443", + "span.type": "resource", + "id": ">www.google.tn:443" + }, + { + "label": "www.googleadservices.com:443", + "span.subtype": "script", + "span.destination.service.resource": "www.googleadservices.com:443", + "span.type": "resource", + "id": ">www.googleadservices.com:443" + }, + { + "label": "www.googletagmanager.com:443", + "span.subtype": "script", + "span.destination.service.resource": "www.googletagmanager.com:443", + "span.type": "resource", + "id": ">www.googletagmanager.com:443" + }, + { + "label": "www.gstatic.com:443", + "span.subtype": "css", + "span.destination.service.resource": "www.gstatic.com:443", + "span.type": "resource", + "id": ">www.gstatic.com:443" + }, + { + "label": "www.iubenda.com:443", + "span.subtype": "script", + "span.destination.service.resource": "www.iubenda.com:443", + "span.type": "resource", + "id": ">www.iubenda.com:443" + }, + { + "label": "www.slideshare.net:443", + "span.subtype": "iframe", + "span.destination.service.resource": "www.slideshare.net:443", + "span.type": "resource", + "id": ">www.slideshare.net:443" + }, + { + "label": "www.youtube.com:443", + "span.subtype": "iframe", + "span.destination.service.resource": "www.youtube.com:443", + "span.type": "resource", + "id": ">www.youtube.com:443" + }, + { + "label": "x.clearbit.com:443", + "span.subtype": "http", + "span.destination.service.resource": "x.clearbit.com:443", + "span.type": "external", + "id": ">x.clearbit.com:443" + } + ] +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_one_domain_many_ips.json b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_one_domain_many_ips.json deleted file mode 100644 index f9b8a273d8577..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_one_domain_many_ips.json +++ /dev/null @@ -1,2122 +0,0 @@ -{ - "elements": [ - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.99:80", - "id": "artifact_api~>192.0.2.99:80", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "http", - "span.destination.service.resource": "192.0.2.99:80", - "span.type": "external", - "id": ">192.0.2.99:80", - "label": ">192.0.2.99:80" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.47:443", - "id": "artifact_api~>192.0.2.47:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.47:443", - "span.type": "external", - "id": ">192.0.2.47:443", - "label": ">192.0.2.47:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.13:443", - "id": "artifact_api~>192.0.2.13:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.13:443", - "span.type": "external", - "id": ">192.0.2.13:443", - "label": ">192.0.2.13:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.106:443", - "id": "artifact_api~>192.0.2.106:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.106:443", - "span.type": "external", - "id": ">192.0.2.106:443", - "label": ">192.0.2.106:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.83:443", - "id": "artifact_api~>192.0.2.83:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.83:443", - "span.type": "external", - "id": ">192.0.2.83:443", - "label": ">192.0.2.83:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.111:443", - "id": "artifact_api~>192.0.2.111:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.111:443", - "span.type": "external", - "id": ">192.0.2.111:443", - "label": ">192.0.2.111:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.189:443", - "id": "artifact_api~>192.0.2.189:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.189:443", - "span.type": "external", - "id": ">192.0.2.189:443", - "label": ">192.0.2.189:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.148:443", - "id": "artifact_api~>192.0.2.148:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.148:443", - "span.type": "external", - "id": ">192.0.2.148:443", - "label": ">192.0.2.148:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.39:443", - "id": "artifact_api~>192.0.2.39:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.39:443", - "span.type": "external", - "id": ">192.0.2.39:443", - "label": ">192.0.2.39:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.42:443", - "id": "artifact_api~>192.0.2.42:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.42:443", - "span.type": "external", - "id": ">192.0.2.42:443", - "label": ">192.0.2.42:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.240:443", - "id": "artifact_api~>192.0.2.240:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.240:443", - "span.type": "external", - "id": ">192.0.2.240:443", - "label": ">192.0.2.240:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.156:443", - "id": "artifact_api~>192.0.2.156:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.156:443", - "span.type": "external", - "id": ">192.0.2.156:443", - "label": ">192.0.2.156:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.245:443", - "id": "artifact_api~>192.0.2.245:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.245:443", - "span.type": "external", - "id": ">192.0.2.245:443", - "label": ">192.0.2.245:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.198:443", - "id": "artifact_api~>192.0.2.198:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.198:443", - "span.type": "external", - "id": ">192.0.2.198:443", - "label": ">192.0.2.198:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.77:443", - "id": "artifact_api~>192.0.2.77:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.77:443", - "span.type": "external", - "id": ">192.0.2.77:443", - "label": ">192.0.2.77:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.8:443", - "id": "artifact_api~>192.0.2.8:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.8:443", - "span.type": "external", - "id": ">192.0.2.8:443", - "label": ">192.0.2.8:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.69:443", - "id": "artifact_api~>192.0.2.69:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.69:443", - "span.type": "external", - "id": ">192.0.2.69:443", - "label": ">192.0.2.69:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.5:443", - "id": "artifact_api~>192.0.2.5:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.5:443", - "span.type": "external", - "id": ">192.0.2.5:443", - "label": ">192.0.2.5:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.139:443", - "id": "artifact_api~>192.0.2.139:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.139:443", - "span.type": "external", - "id": ">192.0.2.139:443", - "label": ">192.0.2.139:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.113:443", - "id": "artifact_api~>192.0.2.113:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.113:443", - "span.type": "external", - "id": ">192.0.2.113:443", - "label": ">192.0.2.113:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.2:443", - "id": "artifact_api~>192.0.2.2:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.2:443", - "span.type": "external", - "id": ">192.0.2.2:443", - "label": ">192.0.2.2:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.213:443", - "id": "artifact_api~>192.0.2.213:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.213:443", - "span.type": "external", - "id": ">192.0.2.213:443", - "label": ">192.0.2.213:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.153:443", - "id": "artifact_api~>192.0.2.153:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.153:443", - "span.type": "external", - "id": ">192.0.2.153:443", - "label": ">192.0.2.153:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.36:443", - "id": "artifact_api~>192.0.2.36:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.36:443", - "span.type": "external", - "id": ">192.0.2.36:443", - "label": ">192.0.2.36:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.164:443", - "id": "artifact_api~>192.0.2.164:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.164:443", - "span.type": "external", - "id": ">192.0.2.164:443", - "label": ">192.0.2.164:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.190:443", - "id": "artifact_api~>192.0.2.190:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.190:443", - "span.type": "external", - "id": ">192.0.2.190:443", - "label": ">192.0.2.190:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.9:443", - "id": "artifact_api~>192.0.2.9:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.9:443", - "span.type": "external", - "id": ">192.0.2.9:443", - "label": ">192.0.2.9:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.210:443", - "id": "artifact_api~>192.0.2.210:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.210:443", - "span.type": "external", - "id": ">192.0.2.210:443", - "label": ">192.0.2.210:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.21:443", - "id": "artifact_api~>192.0.2.21:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.21:443", - "span.type": "external", - "id": ">192.0.2.21:443", - "label": ">192.0.2.21:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.176:443", - "id": "artifact_api~>192.0.2.176:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.176:443", - "span.type": "external", - "id": ">192.0.2.176:443", - "label": ">192.0.2.176:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.81:443", - "id": "artifact_api~>192.0.2.81:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.81:443", - "span.type": "external", - "id": ">192.0.2.81:443", - "label": ">192.0.2.81:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.118:443", - "id": "artifact_api~>192.0.2.118:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.118:443", - "span.type": "external", - "id": ">192.0.2.118:443", - "label": ">192.0.2.118:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.103:443", - "id": "artifact_api~>192.0.2.103:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.103:443", - "span.type": "external", - "id": ">192.0.2.103:443", - "label": ">192.0.2.103:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.3:443", - "id": "artifact_api~>192.0.2.3:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.3:443", - "span.type": "external", - "id": ">192.0.2.3:443", - "label": ">192.0.2.3:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.135:443", - "id": "artifact_api~>192.0.2.135:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.135:443", - "span.type": "external", - "id": ">192.0.2.135:443", - "label": ">192.0.2.135:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.26:443", - "id": "artifact_api~>192.0.2.26:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.26:443", - "span.type": "external", - "id": ">192.0.2.26:443", - "label": ">192.0.2.26:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.185:443", - "id": "artifact_api~>192.0.2.185:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.185:443", - "span.type": "external", - "id": ">192.0.2.185:443", - "label": ">192.0.2.185:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.173:443", - "id": "artifact_api~>192.0.2.173:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.173:443", - "span.type": "external", - "id": ">192.0.2.173:443", - "label": ">192.0.2.173:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.45:443", - "id": "artifact_api~>192.0.2.45:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.45:443", - "span.type": "external", - "id": ">192.0.2.45:443", - "label": ">192.0.2.45:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.144:443", - "id": "artifact_api~>192.0.2.144:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.144:443", - "span.type": "external", - "id": ">192.0.2.144:443", - "label": ">192.0.2.144:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.165:443", - "id": "artifact_api~>192.0.2.165:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.165:443", - "span.type": "external", - "id": ">192.0.2.165:443", - "label": ">192.0.2.165:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.119:443", - "id": "artifact_api~>192.0.2.119:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.119:443", - "span.type": "external", - "id": ">192.0.2.119:443", - "label": ">192.0.2.119:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.186:443", - "id": "artifact_api~>192.0.2.186:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.186:443", - "span.type": "external", - "id": ">192.0.2.186:443", - "label": ">192.0.2.186:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.54:443", - "id": "artifact_api~>192.0.2.54:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.54:443", - "span.type": "external", - "id": ">192.0.2.54:443", - "label": ">192.0.2.54:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.23:443", - "id": "artifact_api~>192.0.2.23:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.23:443", - "span.type": "external", - "id": ">192.0.2.23:443", - "label": ">192.0.2.23:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.34:443", - "id": "artifact_api~>192.0.2.34:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.34:443", - "span.type": "external", - "id": ">192.0.2.34:443", - "label": ">192.0.2.34:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.169:443", - "id": "artifact_api~>192.0.2.169:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.169:443", - "span.type": "external", - "id": ">192.0.2.169:443", - "label": ">192.0.2.169:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.226:443", - "id": "artifact_api~>192.0.2.226:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.226:443", - "span.type": "external", - "id": ">192.0.2.226:443", - "label": ">192.0.2.226:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.82:443", - "id": "artifact_api~>192.0.2.82:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.82:443", - "span.type": "external", - "id": ">192.0.2.82:443", - "label": ">192.0.2.82:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.132:443", - "id": "artifact_api~>192.0.2.132:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.132:443", - "span.type": "external", - "id": ">192.0.2.132:443", - "label": ">192.0.2.132:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.78:443", - "id": "artifact_api~>192.0.2.78:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.78:443", - "span.type": "external", - "id": ">192.0.2.78:443", - "label": ">192.0.2.78:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.71:443", - "id": "artifact_api~>192.0.2.71:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.71:443", - "span.type": "external", - "id": ">192.0.2.71:443", - "label": ">192.0.2.71:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.48:443", - "id": "artifact_api~>192.0.2.48:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.48:443", - "span.type": "external", - "id": ">192.0.2.48:443", - "label": ">192.0.2.48:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.107:443", - "id": "artifact_api~>192.0.2.107:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.107:443", - "span.type": "external", - "id": ">192.0.2.107:443", - "label": ">192.0.2.107:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.239:443", - "id": "artifact_api~>192.0.2.239:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.239:443", - "span.type": "external", - "id": ">192.0.2.239:443", - "label": ">192.0.2.239:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.209:443", - "id": "artifact_api~>192.0.2.209:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.209:443", - "span.type": "external", - "id": ">192.0.2.209:443", - "label": ">192.0.2.209:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.248:443", - "id": "artifact_api~>192.0.2.248:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.248:443", - "span.type": "external", - "id": ">192.0.2.248:443", - "label": ">192.0.2.248:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.18:443", - "id": "artifact_api~>192.0.2.18:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.18:443", - "span.type": "external", - "id": ">192.0.2.18:443", - "label": ">192.0.2.18:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.228:443", - "id": "artifact_api~>192.0.2.228:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.228:443", - "span.type": "external", - "id": ">192.0.2.228:443", - "label": ">192.0.2.228:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.145:443", - "id": "artifact_api~>192.0.2.145:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.145:443", - "span.type": "external", - "id": ">192.0.2.145:443", - "label": ">192.0.2.145:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.25:443", - "id": "artifact_api~>192.0.2.25:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.25:443", - "span.type": "external", - "id": ">192.0.2.25:443", - "label": ">192.0.2.25:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.162:443", - "id": "artifact_api~>192.0.2.162:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.162:443", - "span.type": "external", - "id": ">192.0.2.162:443", - "label": ">192.0.2.162:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.202:443", - "id": "artifact_api~>192.0.2.202:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.202:443", - "span.type": "external", - "id": ">192.0.2.202:443", - "label": ">192.0.2.202:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.60:443", - "id": "artifact_api~>192.0.2.60:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.60:443", - "span.type": "external", - "id": ">192.0.2.60:443", - "label": ">192.0.2.60:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.59:443", - "id": "artifact_api~>192.0.2.59:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.59:443", - "span.type": "external", - "id": ">192.0.2.59:443", - "label": ">192.0.2.59:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.114:443", - "id": "artifact_api~>192.0.2.114:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.114:443", - "span.type": "external", - "id": ">192.0.2.114:443", - "label": ">192.0.2.114:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.215:443", - "id": "artifact_api~>192.0.2.215:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.215:443", - "span.type": "external", - "id": ">192.0.2.215:443", - "label": ">192.0.2.215:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.238:443", - "id": "artifact_api~>192.0.2.238:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.238:443", - "span.type": "external", - "id": ">192.0.2.238:443", - "label": ">192.0.2.238:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.160:443", - "id": "artifact_api~>192.0.2.160:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.160:443", - "span.type": "external", - "id": ">192.0.2.160:443", - "label": ">192.0.2.160:443" - } - } - }, - { - "data": { - "source": "artifact_api", - "target": ">192.0.2.70:443", - "id": "artifact_api~>192.0.2.70:443", - "sourceData": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - }, - "targetData": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.70:443", - "span.type": "external", - "id": ">192.0.2.70:443", - "label": ">192.0.2.70:443" - } - } - }, - { - "data": { - "id": "artifact_api", - "service.environment": "development", - "service.name": "artifact_api", - "agent.name": "nodejs", - "service.framework.name": "express" - } - }, - { - "data": { - "span.subtype": "http", - "span.destination.service.resource": "192.0.2.99:80", - "span.type": "external", - "id": ">192.0.2.99:80", - "label": ">192.0.2.99:80" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.186:443", - "span.type": "external", - "id": ">192.0.2.186:443", - "label": ">192.0.2.186:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.78:443", - "span.type": "external", - "id": ">192.0.2.78:443", - "label": ">192.0.2.78:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.226:443", - "span.type": "external", - "id": ">192.0.2.226:443", - "label": ">192.0.2.226:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.245:443", - "span.type": "external", - "id": ">192.0.2.245:443", - "label": ">192.0.2.245:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.77:443", - "span.type": "external", - "id": ">192.0.2.77:443", - "label": ">192.0.2.77:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.2:443", - "span.type": "external", - "id": ">192.0.2.2:443", - "label": ">192.0.2.2:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.198:443", - "span.type": "external", - "id": ">192.0.2.198:443", - "label": ">192.0.2.198:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.113:443", - "span.type": "external", - "id": ">192.0.2.113:443", - "label": ">192.0.2.113:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.39:443", - "span.type": "external", - "id": ">192.0.2.39:443", - "label": ">192.0.2.39:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.83:443", - "span.type": "external", - "id": ">192.0.2.83:443", - "label": ">192.0.2.83:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.5:443", - "span.type": "external", - "id": ">192.0.2.5:443", - "label": ">192.0.2.5:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.165:443", - "span.type": "external", - "id": ">192.0.2.165:443", - "label": ">192.0.2.165:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.156:443", - "span.type": "external", - "id": ">192.0.2.156:443", - "label": ">192.0.2.156:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.132:443", - "span.type": "external", - "id": ">192.0.2.132:443", - "label": ">192.0.2.132:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.240:443", - "span.type": "external", - "id": ">192.0.2.240:443", - "label": ">192.0.2.240:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.54:443", - "span.type": "external", - "id": ">192.0.2.54:443", - "label": ">192.0.2.54:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.213:443", - "span.type": "external", - "id": ">192.0.2.213:443", - "label": ">192.0.2.213:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.81:443", - "span.type": "external", - "id": ">192.0.2.81:443", - "label": ">192.0.2.81:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.176:443", - "span.type": "external", - "id": ">192.0.2.176:443", - "label": ">192.0.2.176:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.82:443", - "span.type": "external", - "id": ">192.0.2.82:443", - "label": ">192.0.2.82:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.23:443", - "span.type": "external", - "id": ">192.0.2.23:443", - "label": ">192.0.2.23:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.189:443", - "span.type": "external", - "id": ">192.0.2.189:443", - "label": ">192.0.2.189:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.190:443", - "span.type": "external", - "id": ">192.0.2.190:443", - "label": ">192.0.2.190:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.119:443", - "span.type": "external", - "id": ">192.0.2.119:443", - "label": ">192.0.2.119:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.169:443", - "span.type": "external", - "id": ">192.0.2.169:443", - "label": ">192.0.2.169:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.210:443", - "span.type": "external", - "id": ">192.0.2.210:443", - "label": ">192.0.2.210:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.148:443", - "span.type": "external", - "id": ">192.0.2.148:443", - "label": ">192.0.2.148:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.26:443", - "span.type": "external", - "id": ">192.0.2.26:443", - "label": ">192.0.2.26:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.139:443", - "span.type": "external", - "id": ">192.0.2.139:443", - "label": ">192.0.2.139:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.111:443", - "span.type": "external", - "id": ">192.0.2.111:443", - "label": ">192.0.2.111:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.13:443", - "span.type": "external", - "id": ">192.0.2.13:443", - "label": ">192.0.2.13:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.36:443", - "span.type": "external", - "id": ">192.0.2.36:443", - "label": ">192.0.2.36:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.69:443", - "span.type": "external", - "id": ">192.0.2.69:443", - "label": ">192.0.2.69:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.173:443", - "span.type": "external", - "id": ">192.0.2.173:443", - "label": ">192.0.2.173:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.144:443", - "span.type": "external", - "id": ">192.0.2.144:443", - "label": ">192.0.2.144:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.135:443", - "span.type": "external", - "id": ">192.0.2.135:443", - "label": ">192.0.2.135:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.21:443", - "span.type": "external", - "id": ">192.0.2.21:443", - "label": ">192.0.2.21:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.118:443", - "span.type": "external", - "id": ">192.0.2.118:443", - "label": ">192.0.2.118:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.42:443", - "span.type": "external", - "id": ">192.0.2.42:443", - "label": ">192.0.2.42:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.106:443", - "span.type": "external", - "id": ">192.0.2.106:443", - "label": ">192.0.2.106:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.3:443", - "span.type": "external", - "id": ">192.0.2.3:443", - "label": ">192.0.2.3:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.34:443", - "span.type": "external", - "id": ">192.0.2.34:443", - "label": ">192.0.2.34:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.185:443", - "span.type": "external", - "id": ">192.0.2.185:443", - "label": ">192.0.2.185:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.153:443", - "span.type": "external", - "id": ">192.0.2.153:443", - "label": ">192.0.2.153:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.9:443", - "span.type": "external", - "id": ">192.0.2.9:443", - "label": ">192.0.2.9:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.164:443", - "span.type": "external", - "id": ">192.0.2.164:443", - "label": ">192.0.2.164:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.47:443", - "span.type": "external", - "id": ">192.0.2.47:443", - "label": ">192.0.2.47:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.45:443", - "span.type": "external", - "id": ">192.0.2.45:443", - "label": ">192.0.2.45:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.8:443", - "span.type": "external", - "id": ">192.0.2.8:443", - "label": ">192.0.2.8:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.103:443", - "span.type": "external", - "id": ">192.0.2.103:443", - "label": ">192.0.2.103:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.60:443", - "span.type": "external", - "id": ">192.0.2.60:443", - "label": ">192.0.2.60:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.202:443", - "span.type": "external", - "id": ">192.0.2.202:443", - "label": ">192.0.2.202:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.70:443", - "span.type": "external", - "id": ">192.0.2.70:443", - "label": ">192.0.2.70:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.114:443", - "span.type": "external", - "id": ">192.0.2.114:443", - "label": ">192.0.2.114:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.25:443", - "span.type": "external", - "id": ">192.0.2.25:443", - "label": ">192.0.2.25:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.209:443", - "span.type": "external", - "id": ">192.0.2.209:443", - "label": ">192.0.2.209:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.248:443", - "span.type": "external", - "id": ">192.0.2.248:443", - "label": ">192.0.2.248:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.18:443", - "span.type": "external", - "id": ">192.0.2.18:443", - "label": ">192.0.2.18:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.107:443", - "span.type": "external", - "id": ">192.0.2.107:443", - "label": ">192.0.2.107:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.160:443", - "span.type": "external", - "id": ">192.0.2.160:443", - "label": ">192.0.2.160:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.228:443", - "span.type": "external", - "id": ">192.0.2.228:443", - "label": ">192.0.2.228:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.215:443", - "span.type": "external", - "id": ">192.0.2.215:443", - "label": ">192.0.2.215:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.162:443", - "span.type": "external", - "id": ">192.0.2.162:443", - "label": ">192.0.2.162:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.238:443", - "span.type": "external", - "id": ">192.0.2.238:443", - "label": ">192.0.2.238:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.145:443", - "span.type": "external", - "id": ">192.0.2.145:443", - "label": ">192.0.2.145:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.239:443", - "span.type": "external", - "id": ">192.0.2.239:443", - "label": ">192.0.2.239:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.59:443", - "span.type": "external", - "id": ">192.0.2.59:443", - "label": ">192.0.2.59:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.71:443", - "span.type": "external", - "id": ">192.0.2.71:443", - "label": ">192.0.2.71:443" - } - }, - { - "data": { - "span.subtype": "https", - "span.destination.service.resource": "192.0.2.48:443", - "span.type": "external", - "id": ">192.0.2.48:443", - "label": ">192.0.2.48:443" - } - }, - { - "data": { - "service.name": "graphics-worker", - "agent.name": "nodejs", - "service.environment": null, - "service.framework.name": null, - "id": "graphics-worker" - } - } - ] -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx similarity index 95% rename from x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx index f314fbbb1fba0..ae27d4d3baf75 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { act, wait } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import cytoscape from 'cytoscape'; import React, { ReactNode } from 'react'; import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; @@ -60,7 +60,7 @@ describe('EmptyBanner', () => { await act(async () => { cy.add({ data: { id: 'test id' } }); - await wait(() => { + await waitFor(() => { expect(component.container.children.length).toBeGreaterThan(0); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/service_overview.test.tsx.snap similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap rename to x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/service_overview.test.tsx.snap diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/service_overview.test.tsx similarity index 87% rename from x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx rename to x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/service_overview.test.tsx index d8c8f25616560..06e9008d5aebe 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/service_overview.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { render, wait, waitForElement } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { CoreStart } from 'kibana/public'; import { merge } from 'lodash'; import React, { FunctionComponent, ReactChild } from 'react'; @@ -129,11 +129,11 @@ describe('Service Overview -> View', () => { ], }); - const { container, getByText } = renderServiceOverview(); + const { container, findByText } = renderServiceOverview(); // wait for requests to be made - await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); - await waitForElement(() => getByText('My Python Service')); + await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1)); + await findByText('My Python Service'); expect(container.querySelectorAll('.euiTableRow')).toMatchSnapshot(); }); @@ -145,16 +145,14 @@ describe('Service Overview -> View', () => { items: [], }); - const { container, getByText } = renderServiceOverview(); + const { container, findByText } = renderServiceOverview(); // wait for requests to be made - await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1)); // wait for elements to be rendered - await waitForElement(() => - getByText( - "Looks like you don't have any APM services installed. Let's add some!" - ) + await findByText( + "Looks like you don't have any APM services installed. Let's add some!" ); expect(container.querySelectorAll('.euiTableRow')).toMatchSnapshot(); @@ -167,11 +165,11 @@ describe('Service Overview -> View', () => { items: [], }); - const { container, getByText } = renderServiceOverview(); + const { container, findByText } = renderServiceOverview(); // wait for requests to be made - await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); - await waitForElement(() => getByText('No services found')); + await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1)); + await findByText('No services found'); expect(container.querySelectorAll('.euiTableRow')).toMatchSnapshot(); }); @@ -187,7 +185,7 @@ describe('Service Overview -> View', () => { renderServiceOverview(); // wait for requests to be made - await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1)); expect(addWarning).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -208,7 +206,7 @@ describe('Service Overview -> View', () => { renderServiceOverview(); // wait for requests to be made - await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1)); expect(addWarning).not.toHaveBeenCalled(); }); @@ -234,7 +232,7 @@ describe('Service Overview -> View', () => { const { queryByText } = renderServiceOverview(); // wait for requests to be made - await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1)); expect(queryByText('Health')).toBeNull(); }); @@ -261,7 +259,7 @@ describe('Service Overview -> View', () => { const { queryAllByText } = renderServiceOverview(); // wait for requests to be made - await wait(() => expect(httpGet).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(httpGet).toHaveBeenCalledTimes(1)); expect(queryAllByText('Health').length).toBeGreaterThan(1); }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx index 62aa08c223bde..4ccfc5b3013e9 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/link_preview.test.tsx @@ -10,7 +10,7 @@ import { getNodeText, getByTestId, act, - wait, + waitFor, } from '@testing-library/react'; import * as apmApi from '../../../../../../services/rest/createCallApmApi'; @@ -82,7 +82,7 @@ describe('LinkPreview', () => { filters={[{ key: '', value: '' }]} /> ); - await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); + await waitFor(() => expect(callApmApiSpy).toHaveBeenCalled()); expect(getElementValue(container, 'preview-label')).toEqual('foo'); expect( (getByTestId(container, 'preview-link') as HTMLAnchorElement).text diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 56c420878cdba..fea22e890dc10 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fireEvent, render, wait, RenderResult } from '@testing-library/react'; +import { + fireEvent, + render, + waitFor, + RenderResult, +} from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; import * as apmApi from '../../../../../services/rest/createCallApmApi'; @@ -181,7 +186,7 @@ describe('CustomLink', () => { act(() => { fireEvent.click(editButtons[0]); }); - await wait(() => + await waitFor(() => expect(component.queryByText('Create link')).toBeInTheDocument() ); await act(async () => { diff --git a/x-pack/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx b/x-pack/plugins/apm/public/components/app/TraceLink/trace_link.test.tsx similarity index 83% rename from x-pack/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx rename to x-pack/plugins/apm/public/components/app/TraceLink/trace_link.test.tsx index 8d37a8e54d87c..e7c0400290dcb 100644 --- a/x-pack/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx +++ b/x-pack/plugins/apm/public/components/app/TraceLink/trace_link.test.tsx @@ -3,18 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import { shallow } from 'enzyme'; import React, { ReactNode } from 'react'; import { MemoryRouter, RouteComponentProps } from 'react-router-dom'; -import { TraceLink } from '../'; -import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; +import { TraceLink } from './'; +import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, -} from '../../../../context/ApmPluginContext/MockApmPluginContext'; -import * as hooks from '../../../../hooks/useFetcher'; -import * as urlParamsHooks from '../../../../hooks/useUrlParams'; +} from '../../../context/ApmPluginContext/MockApmPluginContext'; +import * as hooks from '../../../hooks/useFetcher'; +import * as urlParamsHooks from '../../../hooks/useUrlParams'; function Wrapper({ children }: { children?: ReactNode }) { return ( @@ -43,13 +43,18 @@ describe('TraceLink', () => { jest.clearAllMocks(); }); - it('renders a transition page', () => { + it('renders a transition page', async () => { const props = ({ match: { params: { traceId: 'x' } }, } as unknown) as RouteComponentProps<{ traceId: string }>; - const component = render(, renderOptions); + let result; + act(() => { + const component = render(, renderOptions); - expect(component.getByText('Fetching trace...')).toBeDefined(); + result = component.getByText('Fetching trace...'); + }); + await waitFor(() => {}); + expect(result).toBeDefined(); }); describe('when no transaction is found', () => { diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx index 8e3d0effb97a6..e3ba02ce42c2e 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx @@ -4,102 +4,95 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { storiesOf } from '@storybook/react'; +import React, { ComponentType } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { EuiThemeProvider } from '../../../../../../../observability/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { TraceAPIResponse } from '../../../../../../server/lib/traces/get_trace'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; import { WaterfallContainer } from './index'; +import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers'; import { + inferredSpans, location, - urlParams, simpleTrace, - traceWithErrors, traceChildStartBeforeParent, - inferredSpans, + traceWithErrors, + urlParams, } from './waterfallContainer.stories.data'; -import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers'; -import { EuiThemeProvider } from '../../../../../../../observability/public'; -storiesOf('app/TransactionDetails/Waterfall', module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'example', - () => { - const waterfall = getWaterfall( - simpleTrace as TraceAPIResponse, - '975c8d5bfd1dd20b' - ); - return ( - - ); - }, - { info: { propTablesExclude: [EuiThemeProvider], source: false } } +export default { + title: 'app/TransactionDetails/Waterfall', + component: WaterfallContainer, + decorators: [ + (Story: ComponentType) => ( + + + + + + + + ), + ], +}; + +export function Example() { + const waterfall = getWaterfall( + simpleTrace as TraceAPIResponse, + '975c8d5bfd1dd20b' + ); + return ( + ); +} -storiesOf('app/TransactionDetails/Waterfall', module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'with errors', - () => { - const waterfall = getWaterfall( - (traceWithErrors as unknown) as TraceAPIResponse, - '975c8d5bfd1dd20b' - ); - return ( - - ); - }, - { info: { propTablesExclude: [EuiThemeProvider], source: false } } +export function WithErrors() { + const waterfall = getWaterfall( + (traceWithErrors as unknown) as TraceAPIResponse, + '975c8d5bfd1dd20b' ); + return ( + + ); +} -storiesOf('app/TransactionDetails/Waterfall', module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'child starts before parent', - () => { - const waterfall = getWaterfall( - traceChildStartBeforeParent as TraceAPIResponse, - '975c8d5bfd1dd20b' - ); - return ( - - ); - }, - { info: { propTablesExclude: [EuiThemeProvider], source: false } } +export function ChildStartsBeforeParent() { + const waterfall = getWaterfall( + traceChildStartBeforeParent as TraceAPIResponse, + '975c8d5bfd1dd20b' + ); + return ( + ); +} -storiesOf('app/TransactionDetails/Waterfall', module) - .addDecorator((storyFn) => {storyFn()}) - .add( - 'inferred spans', - () => { - const waterfall = getWaterfall( - inferredSpans as TraceAPIResponse, - 'f2387d37260d00bd' - ); - return ( - - ); - }, - { info: { propTablesExclude: [EuiThemeProvider], source: false } } +export function InferredSpans() { + const waterfall = getWaterfall( + inferredSpans as TraceAPIResponse, + 'f2387d37260d00bd' + ); + return ( + ); +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx index a65589bdd147f..049c5934813a2 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx @@ -4,33 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -import { storiesOf } from '@storybook/react'; -import React from 'react'; +import React, { ComponentType } from 'react'; +import { MemoryRouter } from 'react-router-dom'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { TransactionGroup } from '../../../../../server/lib/transaction_groups/fetcher'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; import { TransactionList } from './'; -storiesOf('app/TransactionOverview/TransactionList', module).add( - 'Single Row', - () => { - const items: TransactionGroup[] = [ - { - key: { - ['service.name']: 'adminconsole', - ['transaction.name']: - 'GET /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', - }, - transactionName: +export default { + title: 'app/TransactionOverview/TransactionList', + component: TransactionList, + decorators: [ + (Story: ComponentType) => ( + + + + + + ), + ], +}; + +export function SingleRow() { + const items: TransactionGroup[] = [ + { + key: { + ['service.name']: 'adminconsole', + ['transaction.name']: 'GET /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', - serviceName: 'adminconsole', - transactionType: 'request', - p95: 11974156, - averageResponseTime: 8087434.558974359, - transactionsPerMinute: 0.40625, - impact: 100, }, - ]; + transactionName: + 'GET /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', + serviceName: 'adminconsole', + transactionType: 'request', + p95: 11974156, + averageResponseTime: 8087434.558974359, + transactionsPerMinute: 0.40625, + impact: 100, + }, + ]; - return ; - } -); + return ; +} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx deleted file mode 100644 index c9b7c77409840..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiTitle } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import React from 'react'; -import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; -import { ApmHeader } from './'; - -storiesOf('shared/ApmHeader', module) - .addDecorator((storyFn) => { - return ( - {storyFn()} - ); - }) - .add('Example', () => { - return ( - - -

- GET - /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all -

-
-
- ); - }); diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx new file mode 100644 index 0000000000000..4078998bc7e3e --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiTitle } from '@elastic/eui'; +import React, { ComponentType } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { HttpSetup } from '../../../../../../../src/core/public'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; +import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/MockUrlParamsContextProvider'; +import { createCallApmApi } from '../../../services/rest/createCallApmApi'; +import { ApmHeader } from './'; + +export default { + title: 'shared/ApmHeader', + component: ApmHeader, + decorators: [ + (Story: ComponentType) => { + createCallApmApi(({} as unknown) as HttpSetup); + + return ( + + + + + + + + ); + }, + ], +}; + +export function Example() { + return ( + + +

+ GET + /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all +

+
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx similarity index 52% rename from x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx rename to x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx index 9de70d50b25e1..520cc2f423ddd 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx @@ -5,57 +5,78 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import { wait } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { mount } from 'enzyme'; import { createMemoryHistory } from 'history'; import React, { ReactNode } from 'react'; import { Router } from 'react-router-dom'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; import { UrlParamsContext, useUiFilters, -} from '../../../../context/UrlParamsContext'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -import { DatePicker } from '../index'; +} from '../../../context/UrlParamsContext'; +import { IUrlParams } from '../../../context/UrlParamsContext/types'; +import { DatePicker } from './'; const history = createMemoryHistory(); -const mockHistoryPush = jest.spyOn(history, 'push'); -const mockHistoryReplace = jest.spyOn(history, 'replace'); + const mockRefreshTimeRange = jest.fn(); function MockUrlParamsProvider({ - params = {}, + urlParams = {}, children, }: { children: ReactNode; - params?: IUrlParams; + urlParams?: IUrlParams; }) { return ( ); } -function mountDatePicker(params?: IUrlParams) { - return mount( - +function mountDatePicker(urlParams?: IUrlParams) { + const setTimeSpy = jest.fn(); + const getTimeSpy = jest.fn().mockReturnValue({}); + const wrapper = mount( + - + ); + + return { wrapper, setTimeSpy, getTimeSpy }; } describe('DatePicker', () => { + let mockHistoryPush: jest.SpyInstance; + let mockHistoryReplace: jest.SpyInstance; beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => null); + mockHistoryPush = jest.spyOn(history, 'push'); + mockHistoryReplace = jest.spyOn(history, 'replace'); }); afterAll(() => { @@ -76,16 +97,11 @@ describe('DatePicker', () => { ); }); - it('adds missing default value', () => { - mountDatePicker({ - rangeTo: 'now', - refreshInterval: 5000, - }); + it('adds missing `rangeFrom` to url', () => { + mountDatePicker({ rangeTo: 'now', refreshInterval: 5000 }); expect(mockHistoryReplace).toHaveBeenCalledTimes(1); expect(mockHistoryReplace).toHaveBeenCalledWith( - expect.objectContaining({ - search: 'rangeFrom=now-15m&rangeTo=now&refreshInterval=5000', - }) + expect.objectContaining({ search: 'rangeFrom=now-15m&rangeTo=now' }) ); }); @@ -100,9 +116,9 @@ describe('DatePicker', () => { }); it('updates the URL when the date range changes', () => { - const datePicker = mountDatePicker(); + const { wrapper } = mountDatePicker(); expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - datePicker.find(EuiSuperDatePicker).props().onTimeChange({ + wrapper.find(EuiSuperDatePicker).props().onTimeChange({ start: 'updated-start', end: 'updated-end', isInvalid: false, @@ -118,13 +134,13 @@ describe('DatePicker', () => { it('enables auto-refresh when refreshPaused is false', async () => { jest.useFakeTimers(); - const wrapper = mountDatePicker({ + const { wrapper } = mountDatePicker({ refreshPaused: false, refreshInterval: 1000, }); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); - await wait(); + await waitFor(() => {}); expect(mockRefreshTimeRange).toHaveBeenCalled(); wrapper.unmount(); }); @@ -134,7 +150,49 @@ describe('DatePicker', () => { mountDatePicker({ refreshPaused: true, refreshInterval: 1000 }); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); - await wait(); + await waitFor(() => {}); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); }); + + describe('if both `rangeTo` and `rangeFrom` is set', () => { + it('calls setTime ', async () => { + const { setTimeSpy } = mountDatePicker({ + rangeTo: 'now-20m', + rangeFrom: 'now-22m', + }); + expect(setTimeSpy).toHaveBeenCalledWith({ + to: 'now-20m', + from: 'now-22m', + }); + }); + + it('does not update the url', () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + }); + }); + + describe('if `rangeFrom` is missing from the urlParams', () => { + let setTimeSpy: jest.Mock; + beforeEach(() => { + const res = mountDatePicker({ rangeTo: 'now-5m' }); + setTimeSpy = res.setTimeSpy; + }); + + it('does not call setTime', async () => { + expect(setTimeSpy).toHaveBeenCalledTimes(0); + }); + + it('updates the url with the default `rangeFrom` ', async () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeFrom=now-15m' + ); + }); + + it('preserves `rangeTo`', () => { + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeTo=now-5m' + ); + }); + }); }); diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx index b4d716f89169e..f35cc06748911 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx @@ -5,8 +5,7 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import { isEmpty, isEqual, pickBy } from 'lodash'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; @@ -15,14 +14,10 @@ import { clearCache } from '../../../services/rest/callApi'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { TimePickerQuickRange, TimePickerTimeDefaults } from './typings'; -function removeUndefinedAndEmptyProps(obj: T): Partial { - return pickBy(obj, (value) => value !== undefined && !isEmpty(String(value))); -} - export function DatePicker() { const history = useHistory(); const location = useLocation(); - const { core } = useApmPluginContext(); + const { core, plugins } = useApmPluginContext(); const timePickerQuickRanges = core.uiSettings.get( UI_SETTINGS.TIMEPICKER_QUICK_RANGES @@ -32,11 +27,6 @@ export function DatePicker() { UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS ); - const DEFAULT_VALUES = { - rangeFrom: timePickerTimeDefaults.from, - rangeTo: timePickerTimeDefaults.to, - }; - const commonlyUsedRanges = timePickerQuickRanges.map( ({ from, to, display }) => ({ start: from, @@ -76,35 +66,48 @@ export function DatePicker() { updateUrl({ rangeFrom: start, rangeTo: end }); } - const { rangeFrom, rangeTo, refreshPaused, refreshInterval } = urlParams; - const timePickerURLParams = removeUndefinedAndEmptyProps({ - rangeFrom, - rangeTo, - refreshPaused, - refreshInterval, - }); + useEffect(() => { + // set time if both to and from are given in the url + if (urlParams.rangeFrom && urlParams.rangeTo) { + plugins.data.query.timefilter.timefilter.setTime({ + from: urlParams.rangeFrom, + to: urlParams.rangeTo, + }); + return; + } + + // read time from state and update the url + const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); - const nextParams = { - ...DEFAULT_VALUES, - ...timePickerURLParams, - }; - if (!isEqual(nextParams, timePickerURLParams)) { - // When the default parameters are not availbale in the url, replace it adding the necessary parameters. history.replace({ ...location, search: fromQuery({ ...toQuery(location.search), - ...nextParams, + rangeFrom: + urlParams.rangeFrom ?? + timePickerSharedState.from ?? + timePickerTimeDefaults.from, + rangeTo: + urlParams.rangeTo ?? + timePickerSharedState.to ?? + timePickerTimeDefaults.to, }), }); - } + }, [ + urlParams.rangeFrom, + urlParams.rangeTo, + plugins, + history, + location, + timePickerTimeDefaults, + ]); return ( { clearCache(); diff --git a/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx b/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx index 45fa3dd382266..1819e71a49753 100644 --- a/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx @@ -4,31 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { storiesOf } from '@storybook/react'; -import React from 'react'; +import React, { ComponentType } from 'react'; +import { LicensePrompt } from '.'; import { ApmPluginContext, ApmPluginContextValue, } from '../../../context/ApmPluginContext'; -import { LicensePrompt } from '.'; -storiesOf('app/LicensePrompt', module).add( - 'example', - () => { - const contextMock = ({ - core: { http: { basePath: { prepend: () => {} } } }, - } as unknown) as ApmPluginContextValue; +const contextMock = ({ + core: { http: { basePath: { prepend: () => {} } } }, +} as unknown) as ApmPluginContextValue; - return ( +export default { + title: 'app/LicensePrompt', + component: LicensePrompt, + decorators: [ + (Story: ComponentType) => ( - + {' '} - ); - }, - { - info: { - propTablesExclude: [ApmPluginContext.Provider, LicensePrompt], - source: false, - }, - } -); + ), + ], +}; + +export function Example() { + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionButton.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/discover_transaction_button.test.tsx.snap similarity index 100% rename from x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionButton.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/discover_transaction_button.test.tsx.snap diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/discover_transaction_button.test.tsx similarity index 78% rename from x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/discover_transaction_button.test.tsx index 17dca4796ec74..4a68a5c0b4904 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/discover_transaction_button.test.tsx @@ -11,12 +11,11 @@ import { DiscoverTransactionLink, getDiscoverQuery, } from '../DiscoverTransactionLink'; -import mockTransaction from './mockTransaction.json'; +import mockTransaction from './mock_transaction.json'; describe('DiscoverTransactionLink component', () => { it('should render with data', () => { - // @ts-expect-error invalid json mock - const transaction: Transaction = mockTransaction; + const transaction = mockTransaction as Transaction; expect( shallow() @@ -26,8 +25,7 @@ describe('DiscoverTransactionLink component', () => { describe('getDiscoverQuery', () => { it('should return the correct query params object', () => { - // @ts-expect-error invalid json mock - const transaction: Transaction = mockTransaction; + const transaction = mockTransaction as Transaction; const result = getDiscoverQuery(transaction); expect(result).toMatchSnapshot(); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mock_transaction.json similarity index 98% rename from x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mock_transaction.json index 4d038cd7e8397..6c08eedf50b06 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mock_transaction.json @@ -1,6 +1,7 @@ { "agent": { "hostname": "227453131a17", + "name": "go", "type": "apm-server", "version": "7.0.0" }, @@ -91,9 +92,7 @@ }, "name": "GET /api/products/:id/customers", "span_count": { - "dropped": { - "total": 0 - }, + "dropped": 0, "started": 1 }, "id": "8b60bd32ecc6e150", diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/anomaly_detection_setup_link.test.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/anomaly_detection_setup_link.test.tsx index 585ab22b5fb27..3f675f494a661 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/anomaly_detection_setup_link.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { render, fireEvent, wait } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import { MissingJobsAlert } from './AnomalyDetectionSetupLink'; import * as hooks from '../../../../hooks/useFetcher'; @@ -33,7 +33,7 @@ async function renderTooltipAnchor({ fireEvent.mouseOver(toolTipAnchor); // wait for tooltip text to be in the DOM - await wait(() => { + await waitFor(() => { const toolTipText = baseElement.querySelector('.euiToolTipPopover') ?.textContent; expect(toolTipText).not.toBe(undefined); diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap index a5f8c40876540..63093900cb543 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap @@ -238,8 +238,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -2021127760, - "componentId": "sc-fzoLsD", + "baseHash": 211589981, + "componentId": "sc-fznyAO", "isStatic": false, "rules": Array [ " @@ -254,7 +254,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-fzoLsD", + "styledComponentId": "sc-fznyAO", "target": "span", "toString": [Function], "warnTooManyClasses": [Function], @@ -444,8 +444,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -462,7 +462,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -474,8 +474,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -500,7 +500,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -669,8 +669,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -687,7 +687,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -699,8 +699,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -725,7 +725,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -895,8 +895,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -913,7 +913,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -925,8 +925,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -951,7 +951,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -1131,8 +1131,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -1149,7 +1149,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -1161,8 +1161,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -1187,7 +1187,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -1384,8 +1384,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -1402,7 +1402,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -1414,8 +1414,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -1440,7 +1440,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], diff --git a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx index 65f6dca179e71..3b915045f54b6 100644 --- a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx +++ b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -87,6 +87,11 @@ const mockPlugin = { useHash: false, }), }, + data: { + query: { + timefilter: { timefilter: { setTime: () => {}, getTime: () => ({}) } }, + }, + }, }; export const mockApmPluginContextValue = { config: mockConfig, diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx b/x-pack/plugins/apm/public/context/UrlParamsContext/url_params_context.test.tsx similarity index 94% rename from x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx rename to x-pack/plugins/apm/public/context/UrlParamsContext/url_params_context.test.tsx index 9989e568953f5..3a6ccce178cd4 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/url_params_context.test.tsx @@ -5,14 +5,14 @@ */ import * as React from 'react'; -import { UrlParamsContext, UrlParamsProvider } from '..'; +import { UrlParamsContext, UrlParamsProvider } from './'; import { mount } from 'enzyme'; import { Location, History } from 'history'; import { MemoryRouter, Router } from 'react-router-dom'; import moment from 'moment-timezone'; -import { IUrlParams } from '../types'; -import { getParsedDate } from '../helpers'; -import { wait } from '@testing-library/react'; +import { IUrlParams } from './types'; +import { getParsedDate } from './helpers'; +import { waitFor } from '@testing-library/react'; function mountParams(location: Location) { return mount( @@ -119,13 +119,13 @@ describe('UrlParamsContext', () => { ); - await wait(); + await waitFor(() => {}); expect(calls.length).toBe(1); wrapper.find('button').simulate('click'); - await wait(); + await waitFor(() => {}); expect(calls.length).toBe(2); @@ -170,11 +170,11 @@ describe('UrlParamsContext', () => { ); - await wait(); + await waitFor(() => {}); wrapper.find('button').simulate('click'); - await wait(); + await waitFor(() => {}); const params = getDataFromOutput(wrapper); expect(params.start).toEqual('2000-06-14T00:00:00.000Z'); diff --git a/x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx b/x-pack/plugins/apm/public/hooks/use_fetcher.integration.test.tsx similarity index 94% rename from x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx rename to x-pack/plugins/apm/public/hooks/use_fetcher.integration.test.tsx index 0081662200b93..e837851828d94 100644 --- a/x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx +++ b/x-pack/plugins/apm/public/hooks/use_fetcher.integration.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { render, wait } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; @@ -65,7 +65,7 @@ describe('when simulating race condition', () => { it('should render "Hello from Peter" after 200ms', async () => { jest.advanceTimersByTime(200); - await wait(); + await waitFor(() => {}); expect(renderSpy).lastCalledWith({ data: 'Hello from Peter', @@ -76,7 +76,7 @@ describe('when simulating race condition', () => { it('should render "Hello from Peter" after 600ms', async () => { jest.advanceTimersByTime(600); - await wait(); + await waitFor(() => {}); expect(renderSpy).lastCalledWith({ data: 'Hello from Peter', @@ -87,7 +87,7 @@ describe('when simulating race condition', () => { it('should should NOT have rendered "Hello from John" at any point', async () => { jest.advanceTimersByTime(600); - await wait(); + await waitFor(() => {}); expect(renderSpy).not.toHaveBeenCalledWith({ data: 'Hello from John', @@ -98,7 +98,7 @@ describe('when simulating race condition', () => { it('should send and receive calls in the right order', async () => { jest.advanceTimersByTime(600); - await wait(); + await waitFor(() => {}); expect(requestCallOrder).toEqual([ ['request', 'John', 500], diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index d9709bbe461b3..82bed3e29ad11 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -7,6 +7,7 @@ import { ConfigSchema } from '.'; import { FetchDataParams, + HasDataParams, ObservabilityPluginSetup, } from '../../observability/public'; import { @@ -100,6 +101,30 @@ export class ApmPlugin implements Plugin { return await dataHelper.fetchOverviewPageData(params); }, }); + + const getUxDataHelper = async () => { + const { + fetchUxOverviewDate, + hasRumData, + createCallApmApi, + } = await import('./components/app/RumDashboard/ux_overview_fetchers'); + // have to do this here as well in case app isn't mounted yet + createCallApmApi(core.http); + + return { fetchUxOverviewDate, hasRumData }; + }; + + plugins.observability.dashboard.register({ + appName: 'ux', + hasData: async (params?: HasDataParams) => { + const dataHelper = await getUxDataHelper(); + return await dataHelper.hasRumData(params!); + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getUxDataHelper(); + return await dataHelper.fetchUxOverviewDate(params); + }, + }); } core.application.register({ diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index 7826e9672a3bb..f990c4387ddf1 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -12,7 +12,7 @@ import enzymeToJson from 'enzyme-to-json'; import { Location } from 'history'; import moment from 'moment'; import { Moment } from 'moment-timezone'; -import { render, waitForElement } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { APMConfig } from '../../server'; @@ -75,10 +75,10 @@ export async function getRenderedHref(Component: React.FC, location: Location) { ); + const a = el.container.querySelector('a'); - await waitForElement(() => el.container.querySelector('a')); + await waitFor(() => {}, { container: a! }); - const a = el.container.querySelector('a'); return a ? a.getAttribute('href') : ''; } diff --git a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts new file mode 100644 index 0000000000000..14245ce1d6c83 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { TRANSACTION_PAGE_LOAD } from '../../../common/transaction_types'; + +export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) { + try { + const { start, end } = setup; + + const params = { + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [TRANSACTION_TYPE]: TRANSACTION_PAGE_LOAD } }], + }, + }, + aggs: { + services: { + filter: { + range: rangeFilter(start, end), + }, + aggs: { + mostTraffic: { + terms: { + field: SERVICE_NAME, + size: 1, + }, + }, + }, + }, + }, + }, + }; + + const { apmEventClient } = setup; + + const response = await apmEventClient.search(params); + return { + hasData: response.hits.total.value > 0, + serviceName: + response.aggregations?.services?.mostTraffic?.buckets?.[0]?.key, + }; + } catch (e) { + return false; + } +} diff --git a/x-pack/plugins/apm/server/lib/service_map/group_resource_nodes.test.ts b/x-pack/plugins/apm/server/lib/service_map/group_resource_nodes.test.ts index 23ef3f92e21a2..c3238963eedee 100644 --- a/x-pack/plugins/apm/server/lib/service_map/group_resource_nodes.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/group_resource_nodes.test.ts @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ConnectionElement } from '../../../common/service_map'; import { groupResourceNodes } from './group_resource_nodes'; -import preGroupedData from './mock_responses/group_resource_nodes_pregrouped.json'; import expectedGroupedData from './mock_responses/group_resource_nodes_grouped.json'; +import preGroupedData from './mock_responses/group_resource_nodes_pregrouped.json'; describe('groupResourceNodes', () => { it('should group external nodes', () => { - // @ts-expect-error invalid json mock - const responseWithGroups = groupResourceNodes(preGroupedData); + const responseWithGroups = groupResourceNodes( + preGroupedData as { elements: ConnectionElement[] } + ); expect(responseWithGroups.elements).toHaveLength( expectedGroupedData.elements.length ); diff --git a/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/no_versions.json b/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/no_versions.json index fa5c63f1b9a54..863d4bed998e9 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/no_versions.json +++ b/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/no_versions.json @@ -12,7 +12,7 @@ "value": 10000, "relation": "gte" }, - "max_score": null, + "max_score": 0, "hits": [] }, "aggregations": { diff --git a/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/one_version.json b/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/one_version.json index 56303909bcd6f..d74f7bf82c2b9 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/one_version.json +++ b/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/one_version.json @@ -12,7 +12,7 @@ "value": 10000, "relation": "gte" }, - "max_score": null, + "max_score": 0, "hits": [] }, "aggregations": { diff --git a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts index 9bd9c7b7a1040..f30b77f147710 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts @@ -3,14 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { getDerivedServiceAnnotations } from './get_derived_service_annotations'; import { - SearchParamsMock, + ESSearchRequest, + ESSearchResponse, +} from '../../../../typings/elasticsearch'; +import { inspectSearchParams, + SearchParamsMock, } from '../../../utils/test_helpers'; +import { getDerivedServiceAnnotations } from './get_derived_service_annotations'; +import multipleVersions from './__fixtures__/multiple_versions.json'; import noVersions from './__fixtures__/no_versions.json'; import oneVersion from './__fixtures__/one_version.json'; -import multipleVersions from './__fixtures__/multiple_versions.json'; import versionsFirstSeen from './__fixtures__/versions_first_seen.json'; describe('getServiceAnnotations', () => { @@ -31,8 +35,14 @@ describe('getServiceAnnotations', () => { searchAggregatedTransactions: false, }), { - // @ts-expect-error invalid json mock - mockResponse: () => noVersions, + mockResponse: () => + noVersions as ESSearchResponse< + unknown, + ESSearchRequest, + { + restTotalHitsAsInt: false; + } + >, } ); @@ -51,8 +61,14 @@ describe('getServiceAnnotations', () => { searchAggregatedTransactions: false, }), { - // @ts-expect-error invalid json mock - mockResponse: () => oneVersion, + mockResponse: () => + oneVersion as ESSearchResponse< + unknown, + ESSearchRequest, + { + restTotalHitsAsInt: false; + } + >, } ); @@ -76,8 +92,14 @@ describe('getServiceAnnotations', () => { searchAggregatedTransactions: false, }), { - // @ts-expect-error invalid json mock - mockResponse: () => responses.shift(), + mockResponse: () => + (responses.shift() as unknown) as ESSearchResponse< + unknown, + ESSearchRequest, + { + restTotalHitsAsInt: false; + } + >, } ); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 0560b977e708e..c1f13ee646e49 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -79,6 +79,7 @@ import { anomalyDetectionEnvironmentsRoute, } from './settings/anomaly_detection'; import { + rumHasDataRoute, rumClientMetricsRoute, rumJSErrors, rumLongTaskMetrics, @@ -186,7 +187,8 @@ const createApmApi = () => { .add(rumWebCoreVitals) .add(rumJSErrors) .add(rumUrlSearch) - .add(rumLongTaskMetrics); + .add(rumLongTaskMetrics) + .add(rumHasDataRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index 8dee8b759df26..93e62e68fc228 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -18,6 +18,7 @@ import { getWebCoreVitals } from '../lib/rum_client/get_web_core_vitals'; import { getJSErrors } from '../lib/rum_client/get_js_errors'; import { getLongTaskMetrics } from '../lib/rum_client/get_long_task_metrics'; import { getUrlSearch } from '../lib/rum_client/get_url_search'; +import { hasRumData } from '../lib/rum_client/has_rum_data'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -227,3 +228,14 @@ export const rumJSErrors = createRoute(() => ({ }); }, })); + +export const rumHasDataRoute = createRoute(() => ({ + path: '/api/apm/observability_overview/has_rum_data', + params: { + query: t.intersection([uiFiltersRt, rangeRt]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + return await hasRumData({ setup }); + }, +})); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js index faadfd4bb26d7..43999e9bc7fac 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js @@ -5,16 +5,18 @@ */ import React from 'react'; -import { EuiText } from '@elastic/eui'; +import { EuiCallOut, EuiText } from '@elastic/eui'; import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; import { DataSourceStrings } from '../../../i18n'; const { DemoData: strings } = DataSourceStrings; const DemodataDatasource = () => ( - -

{strings.getDescription()}

-
+ + +

{strings.getDescription()}

+
+
); export const demodata = () => ({ diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index 71e3386d821f1..51c86f6604330 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -235,6 +235,11 @@ export const ComponentStrings = { i18n.translate('xpack.canvas.datasourceDatasourceComponent.changeButtonLabel', { defaultMessage: 'Change element data source', }), + getExpressionArgDescription: () => + i18n.translate('xpack.canvas.datasourceDatasourceComponent.expressionArgDescription', { + defaultMessage: + 'The datasource has an argument controlled by an expression. Use the expression editor to modify the datasource.', + }), getPreviewButtonLabel: () => i18n.translate('xpack.canvas.datasourceDatasourceComponent.previewButtonLabel', { defaultMessage: 'Preview data', diff --git a/x-pack/plugins/canvas/public/components/color_picker_popover/__stories__/__snapshots__/color_picker_popover.stories.storyshot b/x-pack/plugins/canvas/public/components/color_picker_popover/__stories__/__snapshots__/color_picker_popover.stories.storyshot index 178cba0c99e4a..6cab47734039b 100644 --- a/x-pack/plugins/canvas/public/components/color_picker_popover/__stories__/__snapshots__/color_picker_popover.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/color_picker_popover/__stories__/__snapshots__/color_picker_popover.stories.storyshot @@ -4,7 +4,6 @@ exports[`Storyshots components/Color/ColorPickerPopover interactive 1`] = `
( + + +

Hello! I am a datasource with a query arg of: {args.query}

+
+
+); + +const testDatasource = () => ({ + name: 'test', + displayName: 'Test Datasource', + help: 'This is a test data source', + image: 'training', + template: templateFromReactComponent(TestDatasource), +}); + +const wrappedTestDatasource = new Datasource(testDatasource()); + +const args = { + query: ['select * from kibana'], +}; + +storiesOf('components/datasource/DatasourceComponent', module) + .addParameters({ + info: { + inline: true, + styles: { + infoBody: { + margin: 20, + }, + infoStory: { + margin: '40px 60px', + width: '320px', + }, + }, + }, + }) + .add('simple datasource', () => ( + + )) + .add('datasource with expression arguments', () => ( + + )); diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_component.js b/x-pack/plugins/canvas/public/components/datasource/datasource_component.js index de9d192e4608c..171153efdac35 100644 --- a/x-pack/plugins/canvas/public/components/datasource/datasource_component.js +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_component.js @@ -17,13 +17,12 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { isEqual } from 'lodash'; -import { ComponentStrings, DataSourceStrings } from '../../../i18n'; +import { ComponentStrings } from '../../../i18n'; import { getDefaultIndex } from '../../lib/es_service'; import { DatasourceSelector } from './datasource_selector'; import { DatasourcePreview } from './datasource_preview'; const { DatasourceDatasourceComponent: strings } = ComponentStrings; -const { DemoData: demoDataStrings } = DataSourceStrings; export class DatasourceComponent extends PureComponent { static propTypes = { @@ -133,14 +132,17 @@ export class DatasourceComponent extends PureComponent { /> ) : null; - const datasourceRender = stateDatasource.render({ - args: stateArgs, - updateArgs, - datasourceDef, - isInvalid, - setInvalid, - defaultIndex, - }); + const datasourceRender = () => + stateDatasource.render({ + args: stateArgs, + updateArgs, + datasourceDef, + isInvalid, + setInvalid, + defaultIndex, + }); + + const hasExpressionArgs = Object.values(stateArgs).some((a) => a && typeof a[0] === 'object'); return ( @@ -157,26 +159,34 @@ export class DatasourceComponent extends PureComponent { {stateDatasource.displayName} - {stateDatasource.name === 'demodata' ? ( - - {datasourceRender} - + {!hasExpressionArgs ? ( + <> + {datasourceRender()} + + + + setPreviewing(true)}> + {strings.getPreviewButtonLabel()} + + + + + {strings.getSaveButtonLabel()} + + + + ) : ( - datasourceRender + +

{strings.getExpressionArgDescription()}

+
)} - - - - setPreviewing(true)}> - {strings.getPreviewButtonLabel()} - - - - - {strings.getSaveButtonLabel()} - - -
{datasourcePreview} diff --git a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot index 939440750c288..7a40bee5fedc3 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot @@ -3,7 +3,6 @@ exports[`Storyshots components/Shapes/ShapePickerPopover default 1`] = `
{ /(lib)?\/ui_metric/, path.resolve(__dirname, '../tasks/mocks/uiMetric') ), + new webpack.NormalModuleReplacementPlugin( + /lib\/es_service/, + path.resolve(__dirname, '../tasks/mocks/esService') + ), ], resolve: { extensions: ['.ts', '.tsx', '.scss', '.mjs', '.html'], diff --git a/x-pack/plugins/canvas/tasks/mocks/esService.ts b/x-pack/plugins/canvas/tasks/mocks/esService.ts new file mode 100644 index 0000000000000..a0c2a42eafd7c --- /dev/null +++ b/x-pack/plugins/canvas/tasks/mocks/esService.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export function getDefaultIndex() { + return Promise.resolve('default-index'); +} diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json index 264fa0438ea11..f79a69c9f4aba 100644 --- a/x-pack/plugins/dashboard_enhanced/kibana.json +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -6,6 +6,7 @@ "requiredPlugins": ["data", "uiActionsEnhanced", "embeddable", "dashboard", "share"], "configPath": ["xpack", "dashboardEnhanced"], "requiredBundles": [ + "embeddable", "kibanaUtils", "embeddableEnhanced", "kibanaReact", diff --git a/x-pack/plugins/dashboard_enhanced/public/index.ts b/x-pack/plugins/dashboard_enhanced/public/index.ts index 53540a4a1ad2e..8bc1dfc9d6c56 100644 --- a/x-pack/plugins/dashboard_enhanced/public/index.ts +++ b/x-pack/plugins/dashboard_enhanced/public/index.ts @@ -14,6 +14,12 @@ export { StartDependencies as DashboardEnhancedStartDependencies, } from './plugin'; +export { + AbstractDashboardDrilldown as DashboardEnhancedAbstractDashboardDrilldown, + AbstractDashboardDrilldownConfig as DashboardEnhancedAbstractDashboardDrilldownConfig, + AbstractDashboardDrilldownParams as DashboardEnhancedAbstractDashboardDrilldownParams, +} from './services/drilldowns/abstract_dashboard_drilldown'; + export function plugin(context: PluginInitializerContext) { return new DashboardEnhancedPlugin(context); } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx new file mode 100644 index 0000000000000..b098d66619814 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/abstract_dashboard_drilldown.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { DashboardStart } from 'src/plugins/dashboard/public'; +import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; +import { + TriggerId, + TriggerContextMapping, +} from '../../../../../../../src/plugins/ui_actions/public'; +import { CollectConfigContainer } from './components'; +import { + UiActionsEnhancedDrilldownDefinition as Drilldown, + UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, + AdvancedUiActionsStart, +} from '../../../../../ui_actions_enhanced/public'; +import { txtGoToDashboard } from './i18n'; +import { + StartServicesGetter, + CollectConfigProps, +} from '../../../../../../../src/plugins/kibana_utils/public'; +import { KibanaURL } from '../../../../../../../src/plugins/share/public'; +import { Config } from './types'; + +export interface Params { + start: StartServicesGetter<{ + uiActionsEnhanced: AdvancedUiActionsStart; + data: DataPublicPluginStart; + dashboard: DashboardStart; + }>; +} + +export abstract class AbstractDashboardDrilldown + implements Drilldown> { + constructor(protected readonly params: Params) {} + + public abstract readonly id: string; + + public abstract readonly supportedTriggers: () => T[]; + + protected abstract getURL(config: Config, context: TriggerContextMapping[T]): Promise; + + public readonly order = 100; + + public readonly getDisplayName = () => txtGoToDashboard; + + public readonly euiIcon = 'dashboardApp'; + + private readonly ReactCollectConfig: React.FC< + CollectConfigProps> + > = (props) => ; + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + dashboardId: '', + useCurrentFilters: true, + useCurrentDateRange: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + if (!config.dashboardId) return false; + return true; + }; + + public readonly getHref = async ( + config: Config, + context: TriggerContextMapping[T] + ): Promise => { + const url = await this.getURL(config, context); + return url.path; + }; + + public readonly execute = async (config: Config, context: TriggerContextMapping[T]) => { + const url = await this.getURL(config, context); + await this.params.start().core.application.navigateToApp(url.appName, { path: url.appPath }); + }; + + protected get urlGenerator() { + const urlGenerator = this.params.start().plugins.dashboard.dashboardUrlGenerator; + if (!urlGenerator) + throw new Error('Dashboard URL generator is required for dashboard drilldown.'); + return urlGenerator; + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx similarity index 96% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx index 5cbf65f7645dd..ddae64d1c0e49 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/collect_config_container.tsx @@ -11,8 +11,8 @@ import { SimpleSavedObject } from '../../../../../../../../src/core/public'; import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; import { txtDestinationDashboardNotFound } from './i18n'; import { CollectConfigProps } from '../../../../../../../../src/plugins/kibana_utils/public'; -import { Config, FactoryContext } from '../types'; -import { Params } from '../drilldown'; +import { Config } from '../types'; +import { Params } from '../abstract_dashboard_drilldown'; const mergeDashboards = ( dashboards: Array>, @@ -34,7 +34,7 @@ const dashboardSavedObjectToMenuItem = ( label: savedObject.attributes.title, }); -interface DashboardDrilldownCollectConfigProps extends CollectConfigProps { +export interface DashboardDrilldownCollectConfigProps extends CollectConfigProps { params: Params; } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.stories.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.stories.tsx rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/index.ts similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/dashboard_drilldown_config/index.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/i18n.ts similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/i18n.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/index.ts new file mode 100644 index 0000000000000..5ec560a55bdaf --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + CollectConfigContainer, + DashboardDrilldownCollectConfigProps, +} from './collect_config_container'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/i18n.ts similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/i18n.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/index.ts similarity index 55% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/index.ts index 49065a96b4f7b..5fc823e341094 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/index.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; export { - DashboardToDashboardDrilldown, - Params as DashboardToDashboardDrilldownParams, -} from './drilldown'; -export { Config } from './types'; + AbstractDashboardDrilldown, + Params as AbstractDashboardDrilldownParams, +} from './abstract_dashboard_drilldown'; +export { Config as AbstractDashboardDrilldownConfig } from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/abstract_dashboard_drilldown/types.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index cd800baaf026e..a2192808c2d40 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -83,7 +83,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType handle.close()} viewMode={'create'} dynamicActionManager={embeddable.enhancements.dynamicActions} - supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())} + triggers={ensureNestedTriggers(embeddable.supportedTriggers())} placeContext={{ embeddable }} /> ), diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx index 0469034094623..56ef25005078b 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -67,7 +67,7 @@ export class FlyoutEditDrilldownAction implements ActionByType handle.close()} viewMode={'manage'} dynamicActionManager={embeddable.enhancements.dynamicActions} - supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())} + triggers={ensureNestedTriggers(embeddable.supportedTriggers())} placeContext={{ embeddable }} /> ), diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts index 4325e3309b898..e1b6493be5200 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts @@ -14,7 +14,7 @@ import { OPEN_FLYOUT_ADD_DRILLDOWN, OPEN_FLYOUT_EDIT_DRILLDOWN, } from './actions'; -import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown'; +import { EmbeddableToDashboardDrilldown } from './embeddable_to_dashboard_drilldown'; import { createStartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public'; declare module '../../../../../../src/plugins/ui_actions/public' { @@ -44,12 +44,6 @@ export class DashboardDrilldownsService { { uiActionsEnhanced: uiActions }: SetupDependencies ) { const start = createStartServicesGetter(core.getStartServices); - const getDashboardUrlGenerator = () => { - const urlGenerator = start().plugins.dashboard.dashboardUrlGenerator; - if (!urlGenerator) - throw new Error('dashboardUrlGenerator is required for dashboard to dashboard drilldown'); - return urlGenerator; - }; const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ start }); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); @@ -57,10 +51,7 @@ export class DashboardDrilldownsService { const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ start }); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); - const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({ - start, - getDashboardUrlGenerator, - }); + const dashboardToDashboardDrilldown = new EmbeddableToDashboardDrilldown({ start }); uiActions.registerDrilldown(dashboardToDashboardDrilldown); } } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx deleted file mode 100644 index 056feeb2b2167..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; -import { APPLY_FILTER_TRIGGER } from '../../../../../../../src/plugins/ui_actions/public'; -import { - DashboardUrlGenerator, - DashboardUrlGeneratorState, -} from '../../../../../../../src/plugins/dashboard/public'; -import { CollectConfigContainer } from './components'; -import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../ui_actions_enhanced/public'; -import { txtGoToDashboard } from './i18n'; -import { - ApplyGlobalFilterActionContext, - esFilters, - isFilters, - isQuery, - isTimeRange, -} from '../../../../../../../src/plugins/data/public'; -import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public'; -import { StartDependencies } from '../../../plugin'; -import { Config, FactoryContext } from './types'; -import { SearchInput } from '../../../../../../../src/plugins/discover/public'; - -export interface Params { - start: StartServicesGetter>; - getDashboardUrlGenerator: () => DashboardUrlGenerator; -} - -export class DashboardToDashboardDrilldown - implements Drilldown { - constructor(protected readonly params: Params) {} - - public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; - - public readonly order = 100; - - public readonly getDisplayName = () => txtGoToDashboard; - - public readonly euiIcon = 'dashboardApp'; - - private readonly ReactCollectConfig: React.FC = (props) => ( - - ); - - public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); - - public readonly createConfig = () => ({ - dashboardId: '', - useCurrentFilters: true, - useCurrentDateRange: true, - }); - - public readonly isConfigValid = (config: Config): config is Config => { - if (!config.dashboardId) return false; - return true; - }; - - public supportedTriggers(): Array { - return [APPLY_FILTER_TRIGGER]; - } - - public readonly getHref = async ( - config: Config, - context: ApplyGlobalFilterActionContext - ): Promise => { - return this.getDestinationUrl(config, context); - }; - - public readonly execute = async (config: Config, context: ApplyGlobalFilterActionContext) => { - const dashboardPath = await this.getDestinationUrl(config, context); - const dashboardHash = dashboardPath.split('#')[1]; - - await this.params.start().core.application.navigateToApp('dashboards', { - path: `#${dashboardHash}`, - }); - }; - - private getDestinationUrl = async ( - config: Config, - context: ApplyGlobalFilterActionContext - ): Promise => { - const state: DashboardUrlGeneratorState = { - dashboardId: config.dashboardId, - }; - - if (context.embeddable) { - const input = context.embeddable.getInput() as Readonly; - if (isQuery(input.query) && config.useCurrentFilters) state.query = input.query; - - // if useCurrentDashboardDataRange is enabled, then preserve current time range - // if undefined is passed, then destination dashboard will figure out time range itself - // for brush event this time range would be overwritten - if (isTimeRange(input.timeRange) && config.useCurrentDateRange) - state.timeRange = input.timeRange; - - // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned and unpinned) - // otherwise preserve only pinned - if (isFilters(input.filters)) - state.filters = config.useCurrentFilters - ? input.filters - : input.filters?.filter((f) => esFilters.isFilterPinned(f)); - } - - const { - restOfFilters: filtersFromEvent, - timeRange: timeRangeFromEvent, - } = esFilters.extractTimeRange(context.filters, context.timeFieldName); - - if (filtersFromEvent) { - state.filters = [...(state.filters ?? []), ...filtersFromEvent]; - } - - if (timeRangeFromEvent) { - state.timeRange = timeRangeFromEvent; - } - - return this.params.getDashboardUrlGenerator().createUrl(state); - }; -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/constants.ts similarity index 68% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/constants.ts index daefcf2d68cc5..922ec36619a4b 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/constants.ts @@ -5,10 +5,10 @@ */ /** - * note: - * don't change this string without carefull consideration, - * because it is stored in saved objects. + * NOTE: DO NOT CHANGE THIS STRING WITHOUT CAREFUL CONSIDERATOIN, BECAUSE IT IS + * STORED IN SAVED OBJECTS. + * * Also temporary dashboard drilldown migration code inside embeddable plugin relies on it * x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts */ -export const DASHBOARD_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; +export const EMBEDDABLE_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx similarity index 88% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx index 40fa469feb34b..f6de2ba931c58 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.test.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DashboardToDashboardDrilldown } from './drilldown'; -import { Config } from './types'; +import { EmbeddableToDashboardDrilldown } from './embeddable_to_dashboard_drilldown'; +import { AbstractDashboardDrilldownConfig as Config } from '../abstract_dashboard_drilldown'; import { coreMock, savedObjectsServiceMock } from '../../../../../../../src/core/public/mocks'; import { Filter, @@ -18,7 +18,6 @@ import { ApplyGlobalFilterActionContext, esFilters, } from '../../../../../../../src/plugins/data/public'; -// convenient to use real implementation here. import { createDashboardUrlGenerator } from '../../../../../../../src/plugins/dashboard/public/url_generator'; import { UrlGeneratorsService } from '../../../../../../../src/plugins/share/public/url_generators'; import { StartDependencies } from '../../../plugin'; @@ -26,7 +25,7 @@ import { SavedObjectLoader } from '../../../../../../../src/plugins/saved_object import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public/core'; describe('.isConfigValid()', () => { - const drilldown = new DashboardToDashboardDrilldown({} as any); + const drilldown = new EmbeddableToDashboardDrilldown({} as any); test('returns false for invalid config with missing dashboard id', () => { expect( @@ -50,19 +49,19 @@ describe('.isConfigValid()', () => { }); test('config component exist', () => { - const drilldown = new DashboardToDashboardDrilldown({} as any); + const drilldown = new EmbeddableToDashboardDrilldown({} as any); expect(drilldown.CollectConfig).toEqual(expect.any(Function)); }); test('initial config: switches are ON', () => { - const drilldown = new DashboardToDashboardDrilldown({} as any); + const drilldown = new EmbeddableToDashboardDrilldown({} as any); const { useCurrentDateRange, useCurrentFilters } = drilldown.createConfig(); expect(useCurrentDateRange).toBe(true); expect(useCurrentFilters).toBe(true); }); test('getHref is defined', () => { - const drilldown = new DashboardToDashboardDrilldown({} as any); + const drilldown = new EmbeddableToDashboardDrilldown({} as any); expect(drilldown.getHref).toBeDefined(); }); @@ -84,7 +83,7 @@ describe('.execute() & getHref', () => { const getUrlForApp = jest.fn((app, opt) => `${app}/${opt.path}`); const savedObjectsClient = savedObjectsServiceMock.createStartContract().client; - const drilldown = new DashboardToDashboardDrilldown({ + const drilldown = new EmbeddableToDashboardDrilldown({ start: ((() => ({ core: { application: { @@ -97,19 +96,24 @@ describe('.execute() & getHref', () => { }, plugins: { uiActionsEnhanced: {}, + dashboard: { + dashboardUrlGenerator: new UrlGeneratorsService() + .setup(coreMock.createSetup()) + .registerUrlGenerator( + createDashboardUrlGenerator(() => + Promise.resolve({ + appBasePath: 'xyz/app/dashboards', + useHashedUrl: false, + savedDashboardLoader: ({} as unknown) as SavedObjectLoader, + }) + ) + ), + }, }, self: {}, - })) as unknown) as StartServicesGetter>, - getDashboardUrlGenerator: () => - new UrlGeneratorsService().setup(coreMock.createSetup()).registerUrlGenerator( - createDashboardUrlGenerator(() => - Promise.resolve({ - appBasePath: 'test', - useHashedUrl: false, - savedDashboardLoader: ({} as unknown) as SavedObjectLoader, - }) - ) - ), + })) as unknown) as StartServicesGetter< + Pick + >, }); const completeConfig: Config = { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx new file mode 100644 index 0000000000000..25bc93ad38b36 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/embeddable_to_dashboard_drilldown.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + TriggerContextMapping, + APPLY_FILTER_TRIGGER, +} from '../../../../../../../src/plugins/ui_actions/public'; +import { DashboardUrlGeneratorState } from '../../../../../../../src/plugins/dashboard/public'; +import { + esFilters, + isFilters, + isQuery, + isTimeRange, +} from '../../../../../../../src/plugins/data/public'; +import { + AbstractDashboardDrilldown, + AbstractDashboardDrilldownParams, + AbstractDashboardDrilldownConfig as Config, +} from '../abstract_dashboard_drilldown'; +import { KibanaURL } from '../../../../../../../src/plugins/share/public'; +import { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants'; + +type Trigger = typeof APPLY_FILTER_TRIGGER; +type Context = TriggerContextMapping[Trigger]; +export type Params = AbstractDashboardDrilldownParams; + +/** + * This drilldown is the "Go to Dashboard" you can find in Dashboard app panles. + * This drilldown can be used on any embeddable and it is tied to embeddables + * in two ways: (1) it works with APPLY_FILTER_TRIGGER, which is usually executed + * by embeddables (but not necessarily); (2) its `getURL` method depends on + * `embeddable` field being present in `context`. + */ +export class EmbeddableToDashboardDrilldown extends AbstractDashboardDrilldown { + public readonly id = EMBEDDABLE_TO_DASHBOARD_DRILLDOWN; + + public readonly supportedTriggers = () => [APPLY_FILTER_TRIGGER] as Trigger[]; + + protected async getURL(config: Config, context: Context): Promise { + const state: DashboardUrlGeneratorState = { + dashboardId: config.dashboardId, + }; + + if (context.embeddable) { + const input = context.embeddable.getInput(); + if (isQuery(input.query) && config.useCurrentFilters) state.query = input.query; + + // if useCurrentDashboardDataRange is enabled, then preserve current time range + // if undefined is passed, then destination dashboard will figure out time range itself + // for brush event this time range would be overwritten + if (isTimeRange(input.timeRange) && config.useCurrentDateRange) + state.timeRange = input.timeRange; + + // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned and unpinned) + // otherwise preserve only pinned + if (isFilters(input.filters)) + state.filters = config.useCurrentFilters + ? input.filters + : input.filters?.filter((f) => esFilters.isFilterPinned(f)); + } + + const { + restOfFilters: filtersFromEvent, + timeRange: timeRangeFromEvent, + } = esFilters.extractTimeRange(context.filters, context.timeFieldName); + + if (filtersFromEvent) { + state.filters = [...(state.filters ?? []), ...filtersFromEvent]; + } + + if (timeRangeFromEvent) { + state.timeRange = timeRangeFromEvent; + } + + const path = await this.urlGenerator.createUrl(state); + const url = new KibanaURL(path); + + return url; + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/index.ts new file mode 100644 index 0000000000000..a48ab02224248 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/embeddable_to_dashboard_drilldown/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EMBEDDABLE_TO_DASHBOARD_DRILLDOWN } from './constants'; +export { + EmbeddableToDashboardDrilldown, + Params as EmbeddableToDashboardDrilldownParams, +} from './embeddable_to_dashboard_drilldown'; diff --git a/x-pack/plugins/data_enhanced/common/index.ts b/x-pack/plugins/data_enhanced/common/index.ts index 012f1204da46a..bcea85556d42e 100644 --- a/x-pack/plugins/data_enhanced/common/index.ts +++ b/x-pack/plugins/data_enhanced/common/index.ts @@ -5,7 +5,11 @@ */ export { - IEnhancedEsSearchRequest, - IAsyncSearchRequest, ENHANCED_ES_SEARCH_STRATEGY, + EQL_SEARCH_STRATEGY, + EqlRequestParams, + EqlSearchStrategyRequest, + EqlSearchStrategyResponse, + IAsyncSearchRequest, + IEnhancedEsSearchRequest, } from './search'; diff --git a/x-pack/plugins/data_enhanced/common/search/index.ts b/x-pack/plugins/data_enhanced/common/search/index.ts index 696938a403e89..9f4141dbcae7d 100644 --- a/x-pack/plugins/data_enhanced/common/search/index.ts +++ b/x-pack/plugins/data_enhanced/common/search/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { - IEnhancedEsSearchRequest, - IAsyncSearchRequest, - ENHANCED_ES_SEARCH_STRATEGY, -} from './types'; +export * from './types'; diff --git a/x-pack/plugins/data_enhanced/common/search/types.ts b/x-pack/plugins/data_enhanced/common/search/types.ts index 24d459ade4bf9..235fcdc325bcb 100644 --- a/x-pack/plugins/data_enhanced/common/search/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/types.ts @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IEsSearchRequest } from '../../../../../src/plugins/data/common'; +import { EqlSearch } from '@elastic/elasticsearch/api/requestParams'; +import { ApiResponse, TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport'; + +import { + IEsSearchRequest, + IKibanaSearchRequest, + IKibanaSearchResponse, +} from '../../../../../src/plugins/data/common'; export const ENHANCED_ES_SEARCH_STRATEGY = 'ese'; @@ -21,3 +28,13 @@ export interface IEnhancedEsSearchRequest extends IEsSearchRequest { */ isRollup?: boolean; } + +export const EQL_SEARCH_STRATEGY = 'eql'; + +export type EqlRequestParams = EqlSearch>; + +export interface EqlSearchStrategyRequest extends IKibanaSearchRequest { + options?: TransportRequestOptions; +} + +export type EqlSearchStrategyResponse = IKibanaSearchResponse>; diff --git a/x-pack/plugins/data_enhanced/server/index.ts b/x-pack/plugins/data_enhanced/server/index.ts index 3c5d5d1e99d13..a0edd2e26ebef 100644 --- a/x-pack/plugins/data_enhanced/server/index.ts +++ b/x-pack/plugins/data_enhanced/server/index.ts @@ -11,6 +11,6 @@ export function plugin(initializerContext: PluginInitializerContext) { return new EnhancedDataServerPlugin(initializerContext); } -export { ENHANCED_ES_SEARCH_STRATEGY } from '../common'; +export { ENHANCED_ES_SEARCH_STRATEGY, EQL_SEARCH_STRATEGY } from '../common'; export { EnhancedDataServerPlugin as Plugin }; diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index a1dff00ddfdd3..ad21216bb7035 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -16,10 +16,10 @@ import { PluginStart as DataPluginStart, usageProvider, } from '../../../../src/plugins/data/server'; -import { enhancedEsSearchStrategyProvider } from './search'; +import { enhancedEsSearchStrategyProvider, eqlSearchStrategyProvider } from './search'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { getUiSettings } from './ui_settings'; -import { ENHANCED_ES_SEARCH_STRATEGY } from '../common'; +import { ENHANCED_ES_SEARCH_STRATEGY, EQL_SEARCH_STRATEGY } from '../common'; interface SetupDependencies { data: DataPluginSetup; @@ -47,6 +47,11 @@ export class EnhancedDataServerPlugin implements Plugin ({ + body: { + is_partial: false, + is_running: false, + took: 162, + timed_out: false, + hits: { + total: { + value: 1, + relation: 'eq', + }, + sequences: [], + }, + }, +}); + +describe('EQL search strategy', () => { + let mockLogger: Logger; + + beforeEach(() => { + mockLogger = ({ debug: jest.fn() } as unknown) as Logger; + }); + + describe('strategy interface', () => { + it('returns a strategy with a `search` function', async () => { + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + expect(typeof eqlSearch.search).toBe('function'); + }); + + it('returns a strategy with a `cancel` function', async () => { + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + expect(typeof eqlSearch.cancel).toBe('function'); + }); + }); + + describe('search()', () => { + let mockEqlSearch: jest.Mock; + let mockEqlGet: jest.Mock; + let mockContext: RequestHandlerContext; + let params: Required['params']; + let options: Required['options']; + + beforeEach(() => { + mockEqlSearch = jest.fn().mockResolvedValueOnce(getMockEqlResponse()); + mockEqlGet = jest.fn().mockResolvedValueOnce(getMockEqlResponse()); + mockContext = ({ + core: { + uiSettings: { + client: { + get: jest.fn(), + }, + }, + elasticsearch: { + client: { + asCurrentUser: { + eql: { + get: mockEqlGet, + search: mockEqlSearch, + }, + }, + }, + }, + }, + } as unknown) as RequestHandlerContext; + params = { + index: 'logstash-*', + body: { query: 'process where 1 == 1' }, + }; + options = { ignore: [400] }; + }); + + describe('async functionality', () => { + it('performs an eql client search with params when no ID is provided', async () => { + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + await eqlSearch.search(mockContext, { options, params }); + const [[request, requestOptions]] = mockEqlSearch.mock.calls; + + expect(request.index).toEqual('logstash-*'); + expect(request.body).toEqual(expect.objectContaining({ query: 'process where 1 == 1' })); + expect(requestOptions).toEqual(expect.objectContaining({ ignore: [400] })); + }); + + it('retrieves the current request if an id is provided', async () => { + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + await eqlSearch.search(mockContext, { id: 'my-search-id' }); + const [[requestParams]] = mockEqlGet.mock.calls; + + expect(mockEqlSearch).not.toHaveBeenCalled(); + expect(requestParams).toEqual(expect.objectContaining({ id: 'my-search-id' })); + }); + }); + + describe('arguments', () => { + it('sends along async search options', async () => { + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + await eqlSearch.search(mockContext, { options, params }); + const [[request]] = mockEqlSearch.mock.calls; + + expect(request).toEqual( + expect.objectContaining({ + wait_for_completion_timeout: '100ms', + keep_alive: '1m', + }) + ); + }); + + it('sends along default search parameters', async () => { + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + await eqlSearch.search(mockContext, { options, params }); + const [[request]] = mockEqlSearch.mock.calls; + + expect(request).toEqual( + expect.objectContaining({ + ignore_unavailable: true, + ignore_throttled: true, + }) + ); + }); + + it('allows search parameters to be overridden', async () => { + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + await eqlSearch.search(mockContext, { + options, + params: { + ...params, + wait_for_completion_timeout: '5ms', + keep_on_completion: false, + }, + }); + const [[request]] = mockEqlSearch.mock.calls; + + expect(request).toEqual( + expect.objectContaining({ + wait_for_completion_timeout: '5ms', + keep_alive: '1m', + keep_on_completion: false, + }) + ); + }); + + it('allows search options to be overridden', async () => { + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + await eqlSearch.search(mockContext, { + options: { ...options, maxRetries: 2, ignore: [300] }, + params, + }); + const [[, requestOptions]] = mockEqlSearch.mock.calls; + + expect(requestOptions).toEqual( + expect.objectContaining({ + max_retries: 2, + ignore: [300], + }) + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts new file mode 100644 index 0000000000000..5fd3b8df87278 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger } from 'kibana/server'; +import { ApiResponse, TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; + +import { + getAsyncOptions, + getDefaultSearchParams, + ISearchStrategy, + toSnakeCase, + shimAbortSignal, +} from '../../../../../src/plugins/data/server'; +import { EqlSearchStrategyRequest, EqlSearchStrategyResponse } from '../../common/search/types'; + +export const eqlSearchStrategyProvider = ( + logger: Logger +): ISearchStrategy => { + return { + cancel: async (context, id) => { + logger.debug(`_eql/delete ${id}`); + await context.core.elasticsearch.client.asCurrentUser.eql.delete({ + id, + }); + }, + search: async (context, request, options) => { + logger.debug(`_eql/search ${JSON.stringify(request.params) || request.id}`); + let promise: TransportRequestPromise; + const eqlClient = context.core.elasticsearch.client.asCurrentUser.eql; + const uiSettingsClient = await context.core.uiSettings.client; + const asyncOptions = getAsyncOptions(); + + if (request.id) { + promise = eqlClient.get({ + id: request.id, + ...toSnakeCase(asyncOptions), + }); + } else { + const { ignoreThrottled, ignoreUnavailable } = await getDefaultSearchParams( + uiSettingsClient + ); + const searchParams = toSnakeCase({ + ignoreThrottled, + ignoreUnavailable, + ...asyncOptions, + ...request.params, + }); + const searchOptions = toSnakeCase({ ...request.options }); + + promise = eqlClient.search( + searchParams as EqlSearchStrategyRequest['params'], + searchOptions as EqlSearchStrategyRequest['options'] + ); + } + + const rawResponse = await shimAbortSignal(promise, options?.abortSignal); + const { id, is_partial: isPartial, is_running: isRunning } = rawResponse.body; + + return { + id, + isPartial, + isRunning, + rawResponse, + }; + }, + }; +}; diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index f3cf67a487a68..7475228724388 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -17,6 +17,8 @@ import { getShardTimeout, toSnakeCase, shimHitsTotal, + getAsyncOptions, + shimAbortSignal, } from '../../../../../src/plugins/data/server'; import { IEnhancedEsSearchRequest } from '../../common'; import { @@ -79,11 +81,7 @@ export const enhancedEsSearchStrategyProvider = ( let promise: TransportRequestPromise; const esClient = context.core.elasticsearch.client.asCurrentUser; const uiSettingsClient = await context.core.uiSettings.client; - - const asyncOptions = { - waitForCompletionTimeout: '100ms', // Wait up to 100ms for the response to return - keepAlive: '1m', // Extend the TTL for this search request by one minute - }; + const asyncOptions = getAsyncOptions(); // If we have an ID, then just poll for that ID, otherwise send the entire request body if (!request.id) { @@ -102,9 +100,7 @@ export const enhancedEsSearchStrategyProvider = ( }); } - // Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 - if (options?.abortSignal) options.abortSignal.addEventListener('abort', () => promise.abort()); - const esResponse = await promise; + const esResponse = await shimAbortSignal(promise, options?.abortSignal); const { id, response, is_partial: isPartial, is_running: isRunning } = esResponse.body; return { id, @@ -139,9 +135,7 @@ export const enhancedEsSearchStrategyProvider = ( querystring, }); - // Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 - if (options?.abortSignal) options.abortSignal.addEventListener('abort', () => promise.abort()); - const esResponse = await promise; + const esResponse = await shimAbortSignal(promise, options?.abortSignal); const response = esResponse.body as SearchResponse; return { diff --git a/x-pack/plugins/data_enhanced/server/search/index.ts b/x-pack/plugins/data_enhanced/server/search/index.ts index f914326f30d32..64a28cea358e5 100644 --- a/x-pack/plugins/data_enhanced/server/search/index.ts +++ b/x-pack/plugins/data_enhanced/server/search/index.ts @@ -5,3 +5,4 @@ */ export { enhancedEsSearchStrategyProvider } from './es_search_strategy'; +export { eqlSearchStrategyProvider } from './eql_search_strategy'; diff --git a/x-pack/plugins/discover_enhanced/kibana.json b/x-pack/plugins/discover_enhanced/kibana.json index da95a0f21a020..01a3624d3e320 100644 --- a/x-pack/plugins/discover_enhanced/kibana.json +++ b/x-pack/plugins/discover_enhanced/kibana.json @@ -7,5 +7,5 @@ "requiredPlugins": ["uiActions", "embeddable", "discover"], "optionalPlugins": ["share", "kibanaLegacy", "usageCollection"], "configPath": ["xpack", "discoverEnhanced"], - "requiredBundles": ["kibanaUtils", "data"] + "requiredBundles": ["kibanaUtils", "data", "share"] } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index 36a844752a1c3..40e7691e621fd 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -10,7 +10,7 @@ import { ViewMode, IEmbeddable } from '../../../../../../src/plugins/embeddable/ import { StartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public'; import { KibanaLegacyStart } from '../../../../../../src/plugins/kibana_legacy/public'; import { CoreStart } from '../../../../../../src/core/public'; -import { KibanaURL } from './kibana_url'; +import { KibanaURL } from '../../../../../../src/plugins/share/public'; import * as shared from './shared'; export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA'; diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts index 0a7be858691af..52946b3dca7a1 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts @@ -13,7 +13,7 @@ import { ApplyGlobalFilterActionContext, esFilters, } from '../../../../../../src/plugins/data/public'; -import { KibanaURL } from './kibana_url'; +import { KibanaURL } from '../../../../../../src/plugins/share/public'; import * as shared from './shared'; import { AbstractExploreDataAction } from './abstract_explore_data_action'; diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts index 6e748030fe107..fdd096e718339 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts @@ -7,7 +7,7 @@ import { Action } from '../../../../../../src/plugins/ui_actions/public'; import { EmbeddableContext } from '../../../../../../src/plugins/embeddable/public'; import { DiscoverUrlGeneratorState } from '../../../../../../src/plugins/discover/public'; -import { KibanaURL } from './kibana_url'; +import { KibanaURL } from '../../../../../../src/plugins/share/public'; import * as shared from './shared'; import { AbstractExploreDataAction } from './abstract_explore_data_action'; diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/kibana_url.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/kibana_url.ts deleted file mode 100644 index 3c25fc2b3c3d1..0000000000000 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/kibana_url.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// TODO: Replace this logic with KibanaURL once it is available. -// https://github.com/elastic/kibana/issues/64497 -export class KibanaURL { - public readonly path: string; - public readonly appName: string; - public readonly appPath: string; - - constructor(path: string) { - const match = path.match(/^.*\/app\/([^\/#]+)(.+)$/); - - if (!match) { - throw new Error('Unexpected Discover URL path.'); - } - - const [, appName, appPath] = match; - - if (!appName || !appPath) { - throw new Error('Could not parse Discover URL path.'); - } - - this.path = path; - this.appName = appName; - this.appPath = appPath; - } -} diff --git a/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts index fffb75451f8ac..9856d3a558e24 100644 --- a/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts @@ -12,7 +12,6 @@ import { import { UiActionsEnhancedSerializedEvent } from '../../../ui_actions_enhanced/public'; import { of } from '../../../../../src/plugins/kibana_utils/public'; // use real const to make test fail in case someone accidentally changes it -import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from '../../../dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown'; import { APPLY_FILTER_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; class TestEmbeddable extends Embeddable { @@ -555,7 +554,7 @@ describe('EmbeddableActionStorage', () => { eventId: '1', triggers: [OTHER_TRIGGER], action: { - factoryId: DASHBOARD_TO_DASHBOARD_DRILLDOWN, + factoryId: 'DASHBOARD_TO_DASHBOARD_DRILLDOWN', name: '', config: {}, }, diff --git a/x-pack/plugins/enterprise_search/common/strip_trailing_slash/index.test.ts b/x-pack/plugins/enterprise_search/common/strip_slashes/index.test.ts similarity index 51% rename from x-pack/plugins/enterprise_search/common/strip_trailing_slash/index.test.ts rename to x-pack/plugins/enterprise_search/common/strip_slashes/index.test.ts index b5d64455b1a90..4d243292b59d9 100644 --- a/x-pack/plugins/enterprise_search/common/strip_trailing_slash/index.test.ts +++ b/x-pack/plugins/enterprise_search/common/strip_slashes/index.test.ts @@ -4,14 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { stripTrailingSlash } from './'; +import { stripTrailingSlash, stripLeadingSlash } from './'; describe('Strip Trailing Slash helper', () => { - it('strips trailing slashes', async () => { + it('strips trailing slashes', () => { expect(stripTrailingSlash('http://trailing.slash/')).toEqual('http://trailing.slash'); }); - it('does nothing is there is no trailing slash', async () => { + it('does nothing if there is no trailing slash', () => { expect(stripTrailingSlash('http://ok.url')).toEqual('http://ok.url'); }); }); + +describe('Strip Leading Slash helper', () => { + it('strips leading slashes', () => { + expect(stripLeadingSlash('/some/long/path/')).toEqual('some/long/path/'); + }); + + it('does nothing if there is no trailing slash', () => { + expect(stripLeadingSlash('ok')).toEqual('ok'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/common/strip_trailing_slash/index.ts b/x-pack/plugins/enterprise_search/common/strip_slashes/index.ts similarity index 68% rename from x-pack/plugins/enterprise_search/common/strip_trailing_slash/index.ts rename to x-pack/plugins/enterprise_search/common/strip_slashes/index.ts index ade9bd8742c97..f5f27dd255af7 100644 --- a/x-pack/plugins/enterprise_search/common/strip_trailing_slash/index.ts +++ b/x-pack/plugins/enterprise_search/common/strip_slashes/index.ts @@ -5,9 +5,14 @@ */ /** - * Small helper for stripping trailing slashes from URLs or paths + * Helpers for stripping trailing or leading slashes from URLs or paths * (usually ones that come in from React Router or API endpoints) */ + export const stripTrailingSlash = (url: string): string => { return url && url.endsWith('/') ? url.slice(0, -1) : url; }; + +export const stripLeadingSlash = (path: string): string => { + return path && path.startsWith('/') ? path.substring(1) : path; +}; diff --git a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts index 6c82206706b32..886597fcd9891 100644 --- a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts +++ b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts @@ -30,3 +30,49 @@ export interface IConfiguredLimits { totalFields: number; }; } + +export interface IGroup { + id: string; + name: string; + createdAt: string; + updatedAt: string; + contentSources: IContentSource[]; + users: IUser[]; + usersCount: number; + color?: string; +} + +export interface IGroupDetails extends IGroup { + contentSources: IContentSourceDetails[]; + canEditGroup: boolean; + canDeleteGroup: boolean; +} + +export interface IUser { + id: string; + name: string | null; + initials: string; + pictureUrl: string | null; + color: string; + email: string; + role?: string; + groupIds: string[]; +} + +export interface IContentSource { + id: string; + serviceType: string; + name: string; +} + +export interface IContentSourceDetails extends IContentSource { + status: string; + statusMessage: string; + documentCount: string; + isFederatedSource: boolean; + searchable: boolean; + supportedByLicense: boolean; + errorReason: number; + allowsReauth: boolean; + boost: number; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx index cfe88d00ce14e..59986c944c23e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx @@ -36,7 +36,7 @@ export const EmptyState: React.FC = () => { return ( <> - + { return ( <> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 0cb9ba106dbb8..c0fd254567910 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -85,7 +85,7 @@ export const EngineOverview: React.FC = () => { return ( <> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx index 34eb76d11a663..4a6c68fa10315 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/error_connecting/error_connecting.tsx @@ -14,7 +14,7 @@ import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemet export const ErrorConnecting: React.FC = () => { return ( <> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index 12a1be3b3e4bd..60d7f6951a478 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -23,9 +23,11 @@ export const SetupGuide: React.FC = () => ( elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm" > diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index ab5b3c9faeea7..3c7979ed3d4b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { Layout, SideNav, SideNavLink } from '../shared/layout'; +import { SideNav, SideNavLink } from '../shared/layout'; import { SetupGuide } from './components/setup_guide'; import { ErrorConnecting } from './components/error_connecting'; import { EngineOverview } from './components/engine_overview'; @@ -51,11 +51,9 @@ describe('AppSearchConfigured', () => { setMockActions({ initializeAppData: () => {} }); }); - it('renders with layout', () => { + it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(Layout)).toHaveLength(1); - expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(EngineOverview)).toHaveLength(1); }); @@ -86,14 +84,6 @@ describe('AppSearchConfigured', () => { expect(wrapper.find(ErrorConnecting)).toHaveLength(1); }); - it('passes readOnlyMode state', () => { - setMockValues({ myRole: {}, readOnlyMode: true }); - - const wrapper = shallow(); - - expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); - }); - describe('ability checks', () => { // TODO: Use this section for routes wrapped in canViewX conditionals // e.g., it('renders settings if a user can view settings') diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 9aa2cce9c74df..49e0a8a484de1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -8,6 +8,7 @@ import React, { useEffect } from 'react'; import { Route, Redirect, Switch } from 'react-router-dom'; import { useActions, useValues } from 'kea'; +import { EuiPage, EuiPageBody } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { getAppSearchUrl } from '../shared/enterprise_search_url'; @@ -17,7 +18,7 @@ import { AppLogic } from './app_logic'; import { IInitialAppData } from '../../../common/types'; import { APP_SEARCH_PLUGIN } from '../../../common/constants'; -import { Layout, SideNav, SideNavLink } from '../shared/layout'; +import { SideNav, SideNavLink } from '../shared/layout'; import { ROOT_PATH, @@ -52,7 +53,7 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC = (props) => { const { initializeAppData } = useActions(AppLogic); const { hasInitialized } = useValues(AppLogic); - const { errorConnecting, readOnlyMode } = useValues(HttpLogic); + const { errorConnecting } = useValues(HttpLogic); useEffect(() => { if (!hasInitialized) initializeAppData(props); @@ -64,23 +65,25 @@ export const AppSearchConfigured: React.FC = (props) => { - } readOnlyMode={readOnlyMode}> - {errorConnecting ? ( - - ) : ( - - - - - - - - - - - - )} - + + + {errorConnecting ? ( + + ) : ( + + + + + + + + + + + + )} + + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/product_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/product_selector.tsx index 5c2d105e69c40..6d76b741d7a97 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/product_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_selector/product_selector.tsx @@ -51,7 +51,7 @@ export const ProductSelector: React.FC = ({ access }) => return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx index fcb3b399c75b0..4197813feba0f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx @@ -23,9 +23,11 @@ export const SetupGuide: React.FC = () => ( elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm" > diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts new file mode 100644 index 0000000000000..82f1c9d8b8914 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/default_meta.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_META = { + page: { + current: 1, + size: 10, + total_pages: 0, + total_results: 0, + }, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts new file mode 100644 index 0000000000000..4d4ff5f52ef20 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DEFAULT_META } from './default_meta'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts index 4d51362a7e11b..a763518d30b99 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts @@ -6,7 +6,10 @@ import { resetContext } from 'kea'; -import { mockKibanaValues } from '../../__mocks__'; +import { mockKibanaValues, mockHttpValues } from '../../__mocks__'; +jest.mock('../http', () => ({ + HttpLogic: { values: { http: mockHttpValues.http } }, +})); import { KibanaLogic, mountKibanaLogic } from './kibana_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index 9519a62ac352b..89ed07f302b03 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -10,6 +10,7 @@ import { FC } from 'react'; import { History } from 'history'; import { ApplicationStart, ChromeBreadcrumb } from 'src/core/public'; +import { HttpLogic } from '../http'; import { createHref, ICreateHrefOptions } from '../react_router_helpers'; interface IKibanaLogicProps { @@ -31,7 +32,8 @@ export const KibanaLogic = kea>({ history: [props.history, {}], navigateToUrl: [ (url: string, options?: ICreateHrefOptions) => { - const href = createHref(url, props.history, options); + const deps = { history: props.history, http: HttpLogic.values.http }; + const href = createHref(url, deps, options); return props.navigateToUrl(href); }, {}, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts index 61a4397486346..aa74d94837eec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../__mocks__/kea.mock'; +import { setMockValues } from '../../__mocks__/kea.mock'; import { mockKibanaValues, mockHistory } from '../../__mocks__'; jest.mock('../react_router_helpers', () => ({ @@ -14,19 +14,64 @@ jest.mock('../react_router_helpers', () => ({ import { letBrowserHandleEvent } from '../react_router_helpers'; import { - useBreadcrumbs, + useGenerateBreadcrumbs, + useEuiBreadcrumbs, useEnterpriseSearchBreadcrumbs, useAppSearchBreadcrumbs, useWorkplaceSearchBreadcrumbs, } from './generate_breadcrumbs'; -describe('useBreadcrumbs', () => { +describe('useGenerateBreadcrumbs', () => { + const mockCurrentPath = (pathname: string) => + setMockValues({ history: { location: { pathname } } }); + + afterAll(() => { + setMockValues({ history: mockHistory }); + }); + + it('accepts a trail of breadcrumb text and generates IBreadcrumb objs based on the current routing path', () => { + const trail = ['Groups', 'Example Group Name', 'Source Prioritization']; + const path = '/groups/{id}/source_prioritization'; + + mockCurrentPath(path); + const breadcrumbs = useGenerateBreadcrumbs(trail); + + expect(breadcrumbs).toEqual([ + { text: 'Groups', path: '/groups' }, + { text: 'Example Group Name', path: '/groups/{id}' }, + { text: 'Source Prioritization', path: '/groups/{id}/source_prioritization' }, + ]); + }); + + it('handles empty arrays gracefully', () => { + mockCurrentPath(''); + expect(useGenerateBreadcrumbs([])).toEqual([]); + }); + + it('attempts to handle mismatched trail/path lengths gracefully', () => { + mockCurrentPath('/page1/page2'); + expect(useGenerateBreadcrumbs(['Page 1', 'Page 2', 'Page 3'])).toEqual([ + { text: 'Page 1', path: '/page1' }, + { text: 'Page 2', path: '/page1/page2' }, + { text: 'Page 3' }, // The missing path falls back to breadcrumb text w/ no link + ]); + + mockCurrentPath('/page1/page2/page3'); + expect(useGenerateBreadcrumbs(['Page 1', 'Page 2'])).toEqual([ + { text: 'Page 1', path: '/page1' }, + { text: 'Page 2', path: '/page1/page2' }, + // the /page3 path is ignored/not used + ]); + }); +}); + +describe('useEuiBreadcrumbs', () => { beforeEach(() => { jest.clearAllMocks(); }); it('accepts an array of breadcrumbs and to the array correctly injects SPA link navigation props', () => { - const breadcrumb = useBreadcrumbs([ + const breadcrumb = useEuiBreadcrumbs([ { text: 'Hello', path: '/hello', @@ -51,7 +96,7 @@ describe('useBreadcrumbs', () => { }); it('prevents default navigation and uses React Router history on click', () => { - const breadcrumb = useBreadcrumbs([{ text: '', path: '/test' }])[0] as any; + const breadcrumb = useEuiBreadcrumbs([{ text: '', path: '/test' }])[0] as any; expect(breadcrumb.href).toEqual('/app/enterprise_search/test'); expect(mockHistory.createHref).toHaveBeenCalled(); @@ -64,7 +109,7 @@ describe('useBreadcrumbs', () => { }); it('does not call createHref if shouldNotCreateHref is passed', () => { - const breadcrumb = useBreadcrumbs([ + const breadcrumb = useEuiBreadcrumbs([ { text: '', path: '/test', shouldNotCreateHref: true }, ])[0] as any; @@ -73,7 +118,7 @@ describe('useBreadcrumbs', () => { }); it('does not prevent default browser behavior on new tab/window clicks', () => { - const breadcrumb = useBreadcrumbs([{ text: '', path: '/' }])[0] as any; + const breadcrumb = useEuiBreadcrumbs([{ text: '', path: '/' }])[0] as any; (letBrowserHandleEvent as jest.Mock).mockImplementationOnce(() => true); breadcrumb.onClick(); @@ -82,7 +127,7 @@ describe('useBreadcrumbs', () => { }); it('does not generate link behavior if path is excluded', () => { - const breadcrumb = useBreadcrumbs([{ text: 'Unclickable breadcrumb' }])[0]; + const breadcrumb = useEuiBreadcrumbs([{ text: 'Unclickable breadcrumb' }])[0]; expect(breadcrumb.href).toBeUndefined(); expect(breadcrumb.onClick).toBeUndefined(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts index 9ef23e6b176d9..e22334aeea371 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts @@ -7,7 +7,8 @@ import { useValues } from 'kea'; import { EuiBreadcrumb } from '@elastic/eui'; -import { KibanaLogic } from '../../shared/kibana'; +import { KibanaLogic } from '../kibana'; +import { HttpLogic } from '../http'; import { ENTERPRISE_SEARCH_PLUGIN, @@ -15,11 +16,11 @@ import { WORKPLACE_SEARCH_PLUGIN, } from '../../../../common/constants'; +import { stripLeadingSlash } from '../../../../common/strip_slashes'; import { letBrowserHandleEvent, createHref } from '../react_router_helpers'; /** - * Generate React-Router-friendly EUI breadcrumb objects - * https://elastic.github.io/eui/#/navigation/breadcrumbs + * Types */ interface IBreadcrumb { @@ -30,15 +31,49 @@ interface IBreadcrumb { shouldNotCreateHref?: boolean; } export type TBreadcrumbs = IBreadcrumb[]; +export type TBreadcrumbTrail = string[]; // A trail of breadcrumb text + +/** + * Generate an array of breadcrumbs based on: + * 1. A passed array of breadcrumb text (the trail prop) + * 2. The current React Router path + * + * To correctly generate working breadcrumbs, ensure the trail array passed to + * SetPageChrome matches up with the routed path. For example, a page with a trail of: + * `['Groups', 'Example Group Name', 'Source Prioritization']` + * should have a router pathname of: + * `'/groups/{example-group-id}/source_prioritization'` + * + * Which should then generate the following breadcrumb output: + * Groups (linked to `/groups`) + * > Example Group Name (linked to `/groups/{example-group-id}`) + * > Source Prioritization (linked to `/groups/{example-group-id}/source_prioritization`) + */ + +export const useGenerateBreadcrumbs = (trail: TBreadcrumbTrail): TBreadcrumbs => { + const { history } = useValues(KibanaLogic); + const pathArray = stripLeadingSlash(history.location.pathname).split('/'); + + return trail.map((text, i) => { + const path = pathArray[i] ? '/' + pathArray.slice(0, i + 1).join('/') : undefined; + return { text, path }; + }); +}; + +/** + * Convert IBreadcrumb objects to React-Router-friendly EUI breadcrumb objects + * https://elastic.github.io/eui/#/navigation/breadcrumbs + */ -export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => { +export const useEuiBreadcrumbs = (breadcrumbs: TBreadcrumbs): EuiBreadcrumb[] => { const { navigateToUrl, history } = useValues(KibanaLogic); + const { http } = useValues(HttpLogic); return breadcrumbs.map(({ text, path, shouldNotCreateHref }) => { - const breadcrumb = { text } as EuiBreadcrumb; + const breadcrumb: EuiBreadcrumb = { text }; if (path) { - breadcrumb.href = createHref(path, history, { shouldNotCreateHref }); + breadcrumb.href = createHref(path, { history, http }, { shouldNotCreateHref }); breadcrumb.onClick = (event) => { if (letBrowserHandleEvent(event)) return; event.preventDefault(); @@ -55,7 +90,7 @@ export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => { */ export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: TBreadcrumbs = []) => - useBreadcrumbs([ + useEuiBreadcrumbs([ { text: ENTERPRISE_SEARCH_PLUGIN.NAME, path: ENTERPRISE_SEARCH_PLUGIN.URL, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx index 2aee224304f89..dcc04100d85a4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../__mocks__/kea.mock'; import '../../__mocks__/shallow_useeffect.mock'; -import '../../__mocks__/react_router_history.mock'; -import { mockKibanaValues } from '../../__mocks__'; +import { setMockValues } from '../../__mocks__/kea.mock'; +import { mockKibanaValues, mockHistory } from '../../__mocks__'; import React from 'react'; import { shallow } from 'enzyme'; jest.mock('./generate_breadcrumbs', () => ({ + useGenerateBreadcrumbs: jest.requireActual('./generate_breadcrumbs').useGenerateBreadcrumbs, useEnterpriseSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs), useAppSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs), useWorkplaceSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs), @@ -33,8 +33,12 @@ import { enterpriseSearchTitle, appSearchTitle, workplaceSearchTitle } from './g import { SetEnterpriseSearchChrome, SetAppSearchChrome, SetWorkplaceSearchChrome } from './'; describe('Set Kibana Chrome helpers', () => { + const mockCurrentPath = (pathname: string) => + setMockValues({ history: { location: { pathname } } }); + beforeEach(() => { jest.clearAllMocks(); + setMockValues({ history: mockHistory }); }); afterEach(() => { @@ -44,7 +48,7 @@ describe('Set Kibana Chrome helpers', () => { describe('SetEnterpriseSearchChrome', () => { it('sets breadcrumbs and document title', () => { - shallow(); + shallow(); expect(enterpriseSearchTitle).toHaveBeenCalledWith(['Hello World']); expect(useEnterpriseSearchBreadcrumbs).toHaveBeenCalledWith([ @@ -55,8 +59,8 @@ describe('Set Kibana Chrome helpers', () => { ]); }); - it('sets empty breadcrumbs and document title when isRoot is true', () => { - shallow(); + it('handles empty trails as a root-level page', () => { + shallow(); expect(enterpriseSearchTitle).toHaveBeenCalledWith([]); expect(useEnterpriseSearchBreadcrumbs).toHaveBeenCalledWith([]); @@ -65,19 +69,19 @@ describe('Set Kibana Chrome helpers', () => { describe('SetAppSearchChrome', () => { it('sets breadcrumbs and document title', () => { - shallow(); + mockCurrentPath('/engines/{name}/curations'); + shallow(); - expect(appSearchTitle).toHaveBeenCalledWith(['Engines']); + expect(appSearchTitle).toHaveBeenCalledWith(['Curations', 'Some Engine', 'Engines']); expect(useAppSearchBreadcrumbs).toHaveBeenCalledWith([ - { - text: 'Engines', - path: '/current-path', - }, + { text: 'Engines', path: '/engines' }, + { text: 'Some Engine', path: '/engines/{name}' }, + { text: 'Curations', path: '/engines/{name}/curations' }, ]); }); - it('sets empty breadcrumbs and document title when isRoot is true', () => { - shallow(); + it('handles empty trails as a root-level page', () => { + shallow(); expect(appSearchTitle).toHaveBeenCalledWith([]); expect(useAppSearchBreadcrumbs).toHaveBeenCalledWith([]); @@ -86,19 +90,25 @@ describe('Set Kibana Chrome helpers', () => { describe('SetWorkplaceSearchChrome', () => { it('sets breadcrumbs and document title', () => { - shallow(); - - expect(workplaceSearchTitle).toHaveBeenCalledWith(['Sources']); + mockCurrentPath('/groups/{id}/source_prioritization'); + shallow( + + ); + + expect(workplaceSearchTitle).toHaveBeenCalledWith([ + 'Source Prioritization', + 'Some Group', + 'Groups', + ]); expect(useWorkplaceSearchBreadcrumbs).toHaveBeenCalledWith([ - { - text: 'Sources', - path: '/current-path', - }, + { text: 'Groups', path: '/groups' }, + { text: 'Some Group', path: '/groups/{id}' }, + { text: 'Source Prioritization', path: '/groups/{id}/source_prioritization' }, ]); }); - it('sets empty breadcrumbs and document title when isRoot is true', () => { - shallow(); + it('handles empty trails as a root-level page', () => { + shallow(); expect(workplaceSearchTitle).toHaveBeenCalledWith([]); expect(useWorkplaceSearchBreadcrumbs).toHaveBeenCalledWith([]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx index 2ae3ca0137d54..a43e7053bb1e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx @@ -6,91 +6,87 @@ import React, { useEffect } from 'react'; import { useValues } from 'kea'; -import { useHistory } from 'react-router-dom'; -import { EuiBreadcrumb } from '@elastic/eui'; import { KibanaLogic } from '../kibana'; import { + useGenerateBreadcrumbs, useEnterpriseSearchBreadcrumbs, useAppSearchBreadcrumbs, useWorkplaceSearchBreadcrumbs, - TBreadcrumbs, + TBreadcrumbTrail, } from './generate_breadcrumbs'; -import { - enterpriseSearchTitle, - appSearchTitle, - workplaceSearchTitle, - TTitle, -} from './generate_title'; +import { enterpriseSearchTitle, appSearchTitle, workplaceSearchTitle } from './generate_title'; /** * Helpers for setting Kibana chrome (breadcrumbs, doc titles) on React view mount * @see https://github.com/elastic/kibana/blob/master/src/core/public/chrome/chrome_service.tsx + * + * Example usage (don't forget to i18n.translate() page titles!): + * + * + * Breadcrumb output: Enterprise Search > App Search > Engines > Example Engine Name > Curations + * Title output: Curations - Example Engine Name - Engines - App Search - Elastic + * + * + * Breadcrumb output: Enterprise Search > Workplace Search + * Title output: Workplace Search - Elastic */ -export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; - -interface IBreadcrumbsProps { - text: string; - isRoot?: never; +interface ISetChromeProps { + trail?: TBreadcrumbTrail; } -interface IRootBreadcrumbsProps { - isRoot: true; - text?: never; -} -type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps; -export const SetEnterpriseSearchChrome: React.FC = ({ text, isRoot }) => { - const history = useHistory(); +export const SetEnterpriseSearchChrome: React.FC = ({ trail = [] }) => { const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic); - const title = isRoot ? [] : [text]; - const docTitle = enterpriseSearchTitle(title as TTitle | []); + const title = reverseArray(trail); + const docTitle = enterpriseSearchTitle(title); - const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; - const breadcrumbs = useEnterpriseSearchBreadcrumbs(crumb as TBreadcrumbs | []); + const crumbs = useGenerateBreadcrumbs(trail); + const breadcrumbs = useEnterpriseSearchBreadcrumbs(crumbs); useEffect(() => { setBreadcrumbs(breadcrumbs); setDocTitle(docTitle); - }, []); + }, [trail]); return null; }; -export const SetAppSearchChrome: React.FC = ({ text, isRoot }) => { - const history = useHistory(); +export const SetAppSearchChrome: React.FC = ({ trail = [] }) => { const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic); - const title = isRoot ? [] : [text]; - const docTitle = appSearchTitle(title as TTitle | []); + const title = reverseArray(trail); + const docTitle = appSearchTitle(title); - const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; - const breadcrumbs = useAppSearchBreadcrumbs(crumb as TBreadcrumbs | []); + const crumbs = useGenerateBreadcrumbs(trail); + const breadcrumbs = useAppSearchBreadcrumbs(crumbs); useEffect(() => { setBreadcrumbs(breadcrumbs); setDocTitle(docTitle); - }, []); + }, [trail]); return null; }; -export const SetWorkplaceSearchChrome: React.FC = ({ text, isRoot }) => { - const history = useHistory(); +export const SetWorkplaceSearchChrome: React.FC = ({ trail = [] }) => { const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic); - const title = isRoot ? [] : [text]; - const docTitle = workplaceSearchTitle(title as TTitle | []); + const title = reverseArray(trail); + const docTitle = workplaceSearchTitle(title); - const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; - const breadcrumbs = useWorkplaceSearchBreadcrumbs(crumb as TBreadcrumbs | []); + const crumbs = useGenerateBreadcrumbs(trail); + const breadcrumbs = useWorkplaceSearchBreadcrumbs(crumbs); useEffect(() => { setBreadcrumbs(breadcrumbs); setDocTitle(docTitle); - }, []); + }, [trail]); return null; }; + +// Small util - performantly reverses an array without mutating the original array +const reverseArray = (array: string[]) => array.slice().reverse(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx index edcfc2c84e3ad..837a565d5525d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx @@ -13,7 +13,7 @@ import { EuiIcon, EuiTitle, EuiText, EuiLink as EuiLinkExternal } from '@elastic import { EuiLink } from '../react_router_helpers'; import { ENTERPRISE_SEARCH_PLUGIN } from '../../../../common/constants'; -import { stripTrailingSlash } from '../../../../common/strip_trailing_slash'; +import { stripTrailingSlash } from '../../../../common/strip_slashes'; import { NavContext, INavContext } from './layout'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/not_found/not_found.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/not_found/not_found.tsx index 40bb5efcc6330..05374cb5f0274 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/not_found/not_found.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/not_found/not_found.tsx @@ -64,7 +64,7 @@ export const NotFound: React.FC = ({ product = {} }) => { return ( <> - + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.test.ts index 5f96beeb42ae4..353c24a342c17 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.test.ts @@ -4,16 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ +import { httpServiceMock } from 'src/core/public/mocks'; import { mockHistory } from '../../__mocks__'; import { createHref } from './'; describe('createHref', () => { + const dependencies = { + history: mockHistory, + http: httpServiceMock.createSetupContract(), + }; + it('generates a path with the React Router basename included', () => { - expect(createHref('/test', mockHistory)).toEqual('/app/enterprise_search/test'); + expect(createHref('/test', dependencies)).toEqual('/app/enterprise_search/test'); }); - it('does not include the basename if shouldNotCreateHref is passed', () => { - expect(createHref('/test', mockHistory, { shouldNotCreateHref: true })).toEqual('/test'); + describe('shouldNotCreateHref', () => { + const options = { shouldNotCreateHref: true }; + + it('does not include the router basename,', () => { + expect(createHref('/test', dependencies, options)).toEqual('/test'); + }); + + it('does include the Kibana basepath,', () => { + const http = httpServiceMock.createSetupContract({ basePath: '/xyz/s/custom-space' }); + const basePathDeps = { ...dependencies, http }; + + expect(createHref('/test', basePathDeps, options)).toEqual('/xyz/s/custom-space/test'); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts index cc8279c80a092..aa2f09a195c8d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/create_href.ts @@ -5,23 +5,35 @@ */ import { History } from 'history'; +import { HttpSetup } from 'src/core/public'; /** - * This helper uses React Router's createHref function to generate links with router basenames accounted for. + * This helper uses React Router's createHref function to generate links with router basenames included. * For example, if we perform navigateToUrl('/engines') within App Search, we expect the app basename - * to be taken into account to be intelligently routed to '/app/enterprise_search/app_search/engines'. + * to be taken into account & intelligently routed to '/app/enterprise_search/app_search/engines'. * * This helper accomplishes that, while still giving us an escape hatch for navigation *between* apps. * For example, if we want to navigate the user from App Search to Enterprise Search we could * navigateToUrl('/app/enterprise_search', { shouldNotCreateHref: true }) + * + * Said escape hatch should still contain all of Kibana's basepaths - for example, + * 'localhost:5601/xyz' when developing locally, or '/s/some-custom-space/' for space basepaths. + * See: https://www.elastic.co/guide/en/kibana/master/kibana-navigation.html + * + * Links completely outside of Kibana should not use our React Router helpers or navigateToUrl. */ +interface ICreateHrefDeps { + history: History; + http: HttpSetup; +} export interface ICreateHrefOptions { shouldNotCreateHref?: boolean; } + export const createHref = ( path: string, - history: History, - options?: ICreateHrefOptions + { history, http }: ICreateHrefDeps, + { shouldNotCreateHref }: ICreateHrefOptions = {} ): string => { - return options?.shouldNotCreateHref ? path : history.createHref({ pathname: path }); + return shouldNotCreateHref ? http.basePath.prepend(path) : history.createHref({ pathname: path }); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx index e0aa5afdf38c1..f9f6ec54e8832 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -8,7 +8,8 @@ import React from 'react'; import { useValues } from 'kea'; import { EuiLink, EuiButton, EuiButtonProps, EuiLinkAnchorProps } from '@elastic/eui'; -import { KibanaLogic } from '../../shared/kibana'; +import { KibanaLogic } from '../kibana'; +import { HttpLogic } from '../http'; import { letBrowserHandleEvent, createHref } from './'; /** @@ -33,9 +34,10 @@ export const EuiReactRouterHelper: React.FC = ({ children, }) => { const { navigateToUrl, history } = useValues(KibanaLogic); + const { http } = useValues(HttpLogic); // Generate the correct link href (with basename etc. accounted for) - const href = createHref(to, history, { shouldNotCreateHref }); + const href = createHref(to, { history, http }, { shouldNotCreateHref }); const reactRouterLinkClick = (event: React.MouseEvent) => { if (onClick) onClick(); // Run any passed click events (e.g. telemetry) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/share_circle.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/share_circle.svg new file mode 100644 index 0000000000000..730f4fb90f601 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/share_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 2553284744e4d..ccc0fe8b38ff3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -18,6 +18,6 @@ describe('WorkplaceSearchNav', () => { expect(wrapper.find(SideNav)).toHaveLength(1); expect(wrapper.find(SideNavLink).first().prop('to')).toEqual('/'); - expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('http://localhost:3002/ws/search'); + expect(wrapper.find(SideNavLink)).toHaveLength(7); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 5572716391112..7070659a951ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -12,6 +12,8 @@ import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { SideNav, SideNavLink } from '../../../shared/layout'; +import { GroupSubNav } from '../../views/groups/components/group_sub_nav'; + import { ORG_SOURCES_PATH, SOURCES_PATH, @@ -35,7 +37,7 @@ export const WorkplaceSearchNav: React.FC = () => { defaultMessage: 'Sources', })} - + }> {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.groups', { defaultMessage: 'Groups', })} @@ -61,11 +63,6 @@ export const WorkplaceSearchNav: React.FC = () => { defaultMessage: 'View my personal dashboard', })} - - {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.search', { - defaultMessage: 'Go to search application', - })} - ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.scss new file mode 100644 index 0000000000000..c79e31370ebcf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.scss @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +.content-section { + padding-bottom: 44px; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx index cc827d7edb0af..559693d4c7891 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx @@ -6,9 +6,10 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { ContentSection } from './'; +import { ViewContentHeader } from '../view_content_header'; const props = { children:
, @@ -20,15 +21,16 @@ describe('ContentSection', () => { const wrapper = shallow(); expect(wrapper.prop('data-test-subj')).toEqual('contentSection'); - expect(wrapper.prop('className')).toEqual('test'); + expect(wrapper.prop('className')).toEqual('test content-section'); expect(wrapper.find('.children')).toHaveLength(1); }); it('displays title and description', () => { const wrapper = shallow(); - expect(wrapper.find(EuiTitle)).toHaveLength(1); - expect(wrapper.find('p').text()).toEqual('bar'); + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual('foo'); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual('bar'); }); it('displays header content', () => { @@ -41,7 +43,8 @@ describe('ContentSection', () => { /> ); - expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s'); + expect(wrapper.find(EuiSpacer).first().prop('size')).toEqual('s'); + expect(wrapper.find(EuiSpacer)).toHaveLength(1); expect(wrapper.find('.header')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx index b2a9eebc72e85..8111324632513 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx @@ -6,15 +6,20 @@ import React from 'react'; -import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { TSpacerSize } from '../../../types'; +import { ViewContentHeader } from '../view_content_header'; + +import './content_section.scss'; + interface IContentSectionProps { children: React.ReactNode; className?: string; title?: React.ReactNode; description?: React.ReactNode; + action?: React.ReactNode; headerChildren?: React.ReactNode; headerSpacer?: TSpacerSize; testSubj?: string; @@ -25,17 +30,15 @@ export const ContentSection: React.FC = ({ className = '', title, description, + action, headerChildren, headerSpacer, testSubj, }) => ( -
+
{title && ( <> - -

{title}

-
- {description &&

{description}

} + {headerChildren} {headerSpacer && } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss new file mode 100644 index 0000000000000..a099b974a0d41 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.scss @@ -0,0 +1,19 @@ +.source-row { + &__icon { + width: 24px; + height: 24px; + } + + &__name { + font-weight: 500; + } + + &__actions { + width: 100px; + } + + &__actions a { + opacity: 1.0; + pointer-events: auto; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx index a2e252c886354..ca01563d81eda 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_row/source_row.tsx @@ -10,7 +10,6 @@ import classNames from 'classnames'; // Prefer importing entire lodash library, e.g. import { get } from "lodash" // eslint-disable-next-line no-restricted-imports import _kebabCase from 'lodash/kebabCase'; -import { Link } from 'react-router-dom'; import { EuiFlexGroup, @@ -24,12 +23,15 @@ import { EuiToolTip, } from '@elastic/eui'; +import { EuiLink } from '../../../../shared/react_router_helpers'; import { SOURCE_STATUSES as statuses } from '../../../constants'; import { IContentSourceDetails } from '../../../types'; import { ADD_SOURCE_PATH, SOURCE_DETAILS_PATH, getContentSourcePath } from '../../../routes'; import { SourceIcon } from '../source_icon'; +import './source_row.scss'; + const CREDENTIALS_INVALID_ERROR_REASON = 1; export interface ISourceRow { @@ -75,14 +77,9 @@ export const SourceRow: React.FC = ({ const imageClass = classNames('source-row__icon', { 'source-row__icon--loading': isIndexing }); const fixLink = ( - + Fix - + ); const remoteTooltip = ( @@ -100,7 +97,12 @@ export const SourceRow: React.FC = ({ return ( - + = ({ {showFix && {fixLink}} {showDetails && ( - Details - + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx index 1bb9ff255f7ed..7d81e9df67289 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx @@ -19,7 +19,7 @@ describe('ViewContentHeader', () => { it('renders with title and alignItems', () => { const wrapper = shallow(); - expect(wrapper.find('h2').text()).toEqual('Header'); + expect(wrapper.find('h3').text()).toEqual('Header'); expect(wrapper.find(EuiFlexGroup).prop('alignItems')).toEqual('flexStart'); }); @@ -35,4 +35,20 @@ describe('ViewContentHeader', () => { expect(wrapper.find('.action')).toHaveLength(1); }); + + it('renders small heading', () => { + const wrapper = shallow( + } /> + ); + + expect(wrapper.find('h4')).toHaveLength(1); + }); + + it('renders large heading', () => { + const wrapper = shallow( + } /> + ); + + expect(wrapper.find('h2')).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx index 0408517fd4ec5..0e2d781020294 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx @@ -15,28 +15,44 @@ interface IViewContentHeaderProps { description?: React.ReactNode; action?: React.ReactNode; alignItems?: FlexGroupAlignItems; + titleSize?: 's' | 'm' | 'l'; } export const ViewContentHeader: React.FC = ({ title, + titleSize = 'm', description, action, alignItems = 'center', -}) => ( - <> - - - -

{title}

-
- {description && ( - -

{description}

-
- )} -
- {action && {action}} -
- - -); +}) => { + let titleElement; + + switch (titleSize) { + case 's': + titleElement =

{title}

; + break; + case 'l': + titleElement =

{title}

; + break; + default: + titleElement =

{title}

; + break; + } + + return ( + <> + + + {titleElement} + {description && ( + +

{description}

+
+ )} +
+ {action && {action}} +
+ + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 25544b4a9bb68..6aa4cf59ab46c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -76,7 +76,6 @@ describe('WorkplaceSearchConfigured', () => { shallow(); expect(initializeAppData).not.toHaveBeenCalled(); - expect(mockKibanaValues.renderHeaderActions).not.toHaveBeenCalled(); }); it('renders ErrorState', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index b4c4217659043..a3c7f7d48a612 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -22,6 +22,7 @@ import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; +import { GroupsRouter } from './views/groups'; export const WorkplaceSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -37,10 +38,11 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { useEffect(() => { if (!hasInitialized) { initializeAppData(props); - renderHeaderActions(WorkplaceSearchHeaderActions); } }, [hasInitialized]); + renderHeaderActions(WorkplaceSearchHeaderActions); + return ( @@ -50,14 +52,13 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { {errorConnecting ? : } - } readOnlyMode={readOnlyMode}> + } restrictWidth readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( - - {/* Will replace with groups component subsequent PR */} -
+ + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index e833dde4c1b72..dfe664c33198c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -50,7 +50,7 @@ export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; export const USERS_PATH = `${ORG_PATH}/users`; export const SECURITY_PATH = `${ORG_PATH}/security`; -export const GROUPS_PATH = `${ORG_PATH}/groups`; +export const GROUPS_PATH = '/groups'; export const GROUP_PATH = `${GROUPS_PATH}/:groupId`; export const GROUP_SOURCE_PRIORITIZATION_PATH = `${GROUPS_PATH}/:groupId/source-prioritization`; @@ -114,3 +114,6 @@ export const getContentSourcePath = ( sourceId: string, isOrganization: boolean ): string => generatePath(isOrganization ? ORG_PATH + path : path, { sourceId }); +export const getGroupPath = (groupId: string) => generatePath(GROUP_PATH, { groupId }); +export const getGroupSourcePrioritizationPath = (groupId: string) => + `${GROUPS_PATH}/${groupId}/source_prioritization`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 3866da738cbb6..e398a868b2466 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -8,42 +8,6 @@ export * from '../../../common/types/workplace_search'; export type TSpacerSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl'; -export interface IGroup { - id: string; - name: string; - createdAt: string; - updatedAt: string; - contentSources: IContentSource[]; - users: IUser[]; - usersCount: number; - color?: string; -} - -export interface IUser { - id: string; - name: string | null; - initials: string; - pictureUrl: string | null; - color: string; - email: string; - role?: string; - groupIds: string[]; -} - -export interface IContentSource { - id: string; - serviceType: string; - name: string; -} - -export interface IContentSourceDetails extends IContentSource { - status: string; - statusMessage: string; - documentCount: string; - isFederatedSource: boolean; - searchable: boolean; - supportedByLicense: boolean; - errorReason: number; - allowsReauth: boolean; - boost: number; +export interface ISourcePriority { + [id: string]: number; } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx index 9ad649c292fb7..68c027047c07f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/error_state/error_state.tsx @@ -18,7 +18,7 @@ import { ViewContentHeader } from '../../components/shared/view_content_header'; export const ErrorState: React.FC = () => { return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx new file mode 100644 index 0000000000000..766aa511ebb2d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, +} from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; + +const ADD_GROUP_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.heading', + { + defaultMessage: 'Add a group', + } +); +const ADD_GROUP_CANCEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.cancel.action', + { + defaultMessage: 'Cancel', + } +); +const ADD_GROUP_SUBMIT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.submit.action', + { + defaultMessage: 'Add Group', + } +); + +export const AddGroupModal: React.FC<{}> = () => { + const { closeNewGroupModal, saveNewGroup, setNewGroupName } = useActions(GroupsLogic); + const { newGroupNameErrors, newGroupName } = useValues(GroupsLogic); + const isInvalid = newGroupNameErrors.length > 0; + const handleFormSumbit = (e: React.FormEvent) => { + e.preventDefault(); + saveNewGroup(); + }; + + return ( + + +
+ + {ADD_GROUP_HEADER} + + + + + setNewGroupName(e.target.value)} + /> + + + + + {ADD_GROUP_CANCEL} + + {ADD_GROUP_SUBMIT} + + +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.tsx new file mode 100644 index 0000000000000..164c938fb5788 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/clear_filters_link.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink } from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; + +const CLEAR_FILTERS = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.clearFilters.action', + { + defaultMessage: 'Clear Filters', + } +); + +export const ClearFiltersLink: React.FC<{}> = () => { + const { resetGroupsFilters } = useActions(GroupsLogic); + + return ( + + + + + + + + + {CLEAR_FILTERS} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.tsx new file mode 100644 index 0000000000000..a7b5d3e83bee2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_list.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiCard, + EuiFieldSearch, + EuiFilterSelectItem, + EuiIcon, + EuiPopoverTitle, + EuiSpacer, +} from '@elastic/eui'; + +import { IUser } from '../../../types'; + +import { UserOptionItem } from './user_option_item'; + +const MAX_VISIBLE_USERS = 20; + +const FILTER_USERS_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.filterUsers.placeholder', + { + defaultMessage: 'Filter users...', + } +); +const NO_USERS_FOUND = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.noUsersFound', + { + defaultMessage: 'No users found', + } +); + +interface IFilterableUsersListProps { + users: IUser[]; + selectedOptions?: string[]; + itemsClickable?: boolean; + isPopover?: boolean; + allGroupUsersLoading?: React.ReactElement; + addFilteredUser(userId: string): void; + removeFilteredUser(userId: string): void; +} + +export const FilterableUsersList: React.FC = ({ + users, + selectedOptions = [], + itemsClickable, + isPopover, + addFilteredUser, + allGroupUsersLoading, + removeFilteredUser, +}) => { + const [filterValue, updateValue] = useState(''); + + const filterUsers = (userId: string): boolean => { + if (!filterValue) return true; + const filterUser = users.find(({ id }) => id === userId) as IUser; + const filteredName = filterUser.name || filterUser.email; + return filteredName.toLowerCase().indexOf(filterValue.toLowerCase()) > -1; + }; + + // Only show the first 20 users in the dropdown. + const availableUsers = users.map(({ id }) => id).filter(filterUsers); + const hiddenUsers = [...availableUsers]; + const visibleUsers = hiddenUsers.splice(0, MAX_VISIBLE_USERS); + + const getOptionEl = (userId: string, index: number): React.ReactElement => { + const checked = selectedOptions.indexOf(userId) > -1 ? 'on' : undefined; + const handleClick = () => (checked ? removeFilteredUser(userId) : addFilteredUser(userId)); + const user = users.filter(({ id }) => id === userId)[0]; + const option = ; + + return itemsClickable ? ( + + {option} + + ) : ( +
+ {option} +
+ ); + }; + + const filterUsersBar = ( + updateValue(e.target.value)} + /> + ); + const noResults = ( + <> + {NO_USERS_FOUND} + + ); + + const options = + visibleUsers.length > 0 ? ( + visibleUsers.map((userId, index) => getOptionEl(userId, index)) + ) : ( + } + description={!!allGroupUsersLoading ? allGroupUsersLoading : noResults} + /> + ); + + const usersList = ( + <> + {hiddenUsers.length > 0 && ( +
+ + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.userListCount', { + defaultMessage: 'Showing {maxVisibleUsers} of {numUsers} users.', + values: { maxVisibleUsers: MAX_VISIBLE_USERS, numUsers: availableUsers.length }, + })} + +
+ )} + {options} + + ); + + return ( + <> + {isPopover ? {filterUsersBar} : filterUsersBar} + {isPopover ?
{usersList}
: usersList} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx new file mode 100644 index 0000000000000..e5fdcc3089059 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/filterable_users_popover.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions } from 'kea'; + +import { EuiFilterGroup, EuiPopover } from '@elastic/eui'; + +import { IUser } from '../../../types'; + +import { GroupsLogic } from '../groups_logic'; +import { FilterableUsersList } from './filterable_users_list'; + +interface IIFilterableUsersPopoverProps { + users: IUser[]; + selectedOptions?: string[]; + itemsClickable?: boolean; + isPopoverOpen: boolean; + allGroupUsersLoading?: React.ReactElement; + className?: string; + button: React.ReactElement; + closePopover(): void; +} + +export const FilterableUsersPopover: React.FC = ({ + users, + selectedOptions = [], + itemsClickable, + isPopoverOpen, + allGroupUsersLoading, + className, + button, + closePopover, +}) => { + const { addFilteredUser, removeFilteredUser } = useActions(GroupsLogic); + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx new file mode 100644 index 0000000000000..db576808b66e3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiButtonEmpty, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, +} from '@elastic/eui'; + +import { EuiButton as EuiLinkButton } from '../../../../shared/react_router_helpers'; + +import { IGroup } from '../../../types'; +import { ORG_SOURCES_PATH } from '../../../routes'; + +import noSharedSourcesIcon from '../../../assets/share_circle.svg'; + +import { GroupLogic } from '../group_logic'; +import { GroupsLogic } from '../groups_logic'; + +const CANCEL_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerCancel', + { + defaultMessage: 'Cancel', + } +); +const UPDATE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerUpdate', + { + defaultMessage: 'Update', + } +); +const ADD_SOURCE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerUpdateAddSourceButton', + { + defaultMessage: 'Add a Shared Source', + } +); +const EMPTY_STATE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSourceEmpty.title', + { + defaultMessage: 'Whoops!', + } +); +const EMPTY_STATE_BODY = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSourceEmpty.body', + { + defaultMessage: 'Looks like you have not added any shared content sources yet.', + } +); + +interface IGroupManagerModalProps { + children: React.ReactElement; + label: string; + allItems: object[]; + numSelected: number; + hideModal(group: IGroup): void; + selectAll(allItems: object[]): void; + saveItems(): void; +} + +export const GroupManagerModal: React.FC = ({ + children, + label, + allItems, + numSelected, + hideModal, + selectAll, + saveItems, +}) => { + const { group, managerModalFormErrors } = useValues(GroupLogic); + const { contentSources } = useValues(GroupsLogic); + + const allSelected = numSelected === allItems.length; + const isSources = label === 'shared content sources'; + const showEmptyState = isSources && contentSources.length < 1; + const handleClose = () => hideModal(group); + const handleSelectAll = () => selectAll(allSelected ? [] : allItems); + + const sourcesButton = ( + + {ADD_SOURCE_BUTTON_TEXT} + + ); + + const emptyState = ( + + + {EMPTY_STATE_TITLE}

} + body={EMPTY_STATE_BODY} + actions={sourcesButton} + /> + + ); + + const modalContent = ( + <> + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.groupManagerHeaderTitle', { + defaultMessage: 'Manage {label}', + values: { label }, + })} + + + + + + 0} + fullWidth + > + {children} + + + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSelectAllToggle', + { + defaultMessage: '{action} All', + values: { action: allSelected ? 'Deselect' : 'Select' }, + } + )} + + + + + + {CANCEL_BUTTON_TEXT} + + + + {UPDATE_BUTTON_TEXT} + + + + + + + + ); + + return ( + + + {showEmptyState ? emptyState : modalContent} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx new file mode 100644 index 0000000000000..983dede7bd4e8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx @@ -0,0 +1,274 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiConfirmModal, + EuiOverlayMask, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiHorizontalRule, +} from '@elastic/eui'; + +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../../shared/telemetry'; + +import { AppLogic } from '../../../app_logic'; +import { TruncatedContent } from '../../../../shared/truncate'; +import { ContentSection } from '../../../components/shared/content_section'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { Loading } from '../../../components/shared/loading'; +import { SourcesTable } from '../../../components/shared/sources_table'; + +import { GroupUsersTable } from './group_users_table'; + +import { GroupLogic, MAX_NAME_LENGTH } from '../group_logic'; + +const EMPTY_SOURCES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.emptySourcesDescription', + { + defaultMessage: 'No content sources are shared with this group.', + } +); +const GROUP_USERS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.groupUsersDescription', + { + defaultMessage: 'Members will be able to search over the group’s sources.', + } +); +const EMPTY_USERS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.emptyUsersDescription', + { + defaultMessage: 'There are no users in this group.', + } +); +const MANAGE_SOURCES_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.manageSourcesButtonText', + { + defaultMessage: 'Manage shared content sources', + } +); +const MANAGE_USERS_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.manageUsersButtonText', + { + defaultMessage: 'Manage users', + } +); +const NAME_SECTION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.nameSectionTitle', + { + defaultMessage: 'Group name', + } +); +const NAME_SECTION_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.nameSectionDescription', + { + defaultMessage: 'Customize the name of this group.', + } +); +const SAVE_NAME_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.saveNameButtonText', + { + defaultMessage: 'Save name', + } +); +const REMOVE_SECTION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.removeSectionTitle', + { + defaultMessage: 'Remove this group', + } +); +const REMOVE_SECTION_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.removeSectionDescription', + { + defaultMessage: 'This action cannot be undone.', + } +); +const REMOVE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.removeButtonText', + { + defaultMessage: 'Remove group', + } +); +const CANCEL_REMOVE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.cancelRemoveButtonText', + { + defaultMessage: 'Cancel', + } +); +const CONFIRM_TITLE_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmTitleText', + { + defaultMessage: 'Confirm', + } +); + +export const GroupOverview: React.FC = () => { + const { + deleteGroup, + showSharedSourcesModal, + showManageUsersModal, + showConfirmDeleteModal, + hideConfirmDeleteModal, + updateGroupName, + onGroupNameInputChange, + } = useActions(GroupLogic); + const { + group: { name, contentSources, users, canDeleteGroup }, + groupNameInputValue, + dataLoading, + confirmDeleteModalVisible, + } = useValues(GroupLogic); + + const { isFederatedAuth } = useValues(AppLogic); + + if (dataLoading) return ; + + const truncatedName = ( + + ); + + const CONFIRM_REMOVE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmRemoveButtonText', + { + defaultMessage: 'Delete {name}', + values: { name }, + } + ); + const CONFIRM_REMOVE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmRemoveDescription', + { + defaultMessage: + 'Your group will be deleted from Workplace Search. Are you sure you want to remove {name}?', + values: { name }, + } + ); + const GROUP_SOURCES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.overview.groupSourcesDescription', + { + defaultMessage: 'Searchable by all users in the "{name}" group.', + values: { name }, + } + ); + + const hasContentSources = contentSources.length > 0; + const hasUsers = users.length > 0; + + const manageSourcesButton = ( + + {MANAGE_SOURCES_BUTTON_TEXT} + + ); + const manageUsersButton = !isFederatedAuth && ( + + {MANAGE_USERS_BUTTON_TEXT} + + ); + const sourcesTable = ; + + const sourcesSection = ( + + {hasContentSources && sourcesTable} + + ); + + const usersSection = !isFederatedAuth && ( + + {hasUsers && } + + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateGroupName(); + }; + + const nameSection = ( + +
+ + + + onGroupNameInputChange(e.target.value)} + /> + + + + {SAVE_NAME_BUTTON_TEXT} + + + + +
+
+ ); + + const deleteSection = ( + <> + + + + + {confirmDeleteModalVisible && ( + + + {CONFIRM_REMOVE_DESCRIPTION} + + + )} + + {REMOVE_BUTTON_TEXT} + + + + ); + + return ( + <> + + + + + + {sourcesSection} + {usersSection} + {nameSection} + {canDeleteGroup && deleteSection} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx new file mode 100644 index 0000000000000..9c7276372cf54 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import moment from 'moment'; +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiTableRow, EuiTableRowCell, EuiIcon } from '@elastic/eui'; + +import { TruncatedContent } from '../../../../shared/truncate'; +import { EuiLink } from '../../../../shared/react_router_helpers'; + +import { IGroup } from '../../../types'; + +import { AppLogic } from '../../../app_logic'; +import { getGroupPath } from '../../../routes'; +import { MAX_NAME_LENGTH } from '../group_logic'; +import { GroupSources } from './group_sources'; +import { GroupUsers } from './group_users'; + +const DAYS_CUTOFF = 8; +const NO_SOURCES_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.noSourcesMessage', + { + defaultMessage: 'No shared content sources', + } +); +const NO_USERS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.noUsersMessage', + { + defaultMessage: 'No users', + } +); + +const dateDisplay = (date: string) => + moment(date).isAfter(moment().subtract(DAYS_CUTOFF, 'days')) + ? moment(date).fromNow() + : moment(date).format('MMMM D, YYYY'); + +export const GroupRow: React.FC = ({ + id, + name, + updatedAt, + contentSources, + users, + usersCount, +}) => { + const { isFederatedAuth } = useValues(AppLogic); + + const GROUP_UPDATED_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupUpdatedText', + { + defaultMessage: 'Last updated {updatedAt}.', + values: { updatedAt: dateDisplay(updatedAt) }, + } + ); + + return ( + + + + + + + +
+ {GROUP_UPDATED_TEXT} +
+ +
+ {contentSources.length > 0 ? ( + + ) : ( + NO_SOURCES_MESSAGE + )} +
+
+ {!isFederatedAuth && ( + +
+ {usersCount > 0 ? ( + + ) : ( + NO_USERS_MESSAGE + )} +
+
+ )} + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.tsx new file mode 100644 index 0000000000000..3e3840eab33da --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_sources_dropdown.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFilterGroup, EuiPopover, EuiPopoverTitle, EuiButtonEmpty } from '@elastic/eui'; + +import { IContentSource } from '../../../types'; + +import { SourceOptionItem } from './source_option_item'; + +interface IGroupRowSourcesDropdownProps { + isPopoverOpen: boolean; + numOptions: number; + groupSources: IContentSource[]; + onButtonClick(): void; + closePopover(): void; +} + +export const GroupRowSourcesDropdown: React.FC = ({ + isPopoverOpen, + numOptions, + groupSources, + onButtonClick, + closePopover, +}) => { + const toggleLink = ( + + + {numOptions} + + ); + const contentSourceCountHeading = ( + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.contentSourceCountHeading', { + defaultMessage: '{numSources} shared content sources', + values: { numSources: groupSources.length }, + })} + + ); + + const sources = groupSources.map((source, index) => ( +
+ id === source.id)[0]} /> +
+ )); + + return ( + + + {contentSourceCountHeading} +
{sources}
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.tsx new file mode 100644 index 0000000000000..7ecf01db9c044 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_row_users_dropdown.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiLoadingContent, EuiButtonEmpty } from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; +import { FilterableUsersPopover } from './filterable_users_popover'; + +interface IGroupRowUsersDropdownProps { + isPopoverOpen: boolean; + numOptions: number; + groupId: string; + onButtonClick(): void; + closePopover(): void; +} + +export const GroupRowUsersDropdown: React.FC = ({ + isPopoverOpen, + numOptions, + groupId, + onButtonClick, + closePopover, +}) => { + const { fetchGroupUsers } = useActions(GroupsLogic); + const { allGroupUsersLoading, allGroupUsers } = useValues(GroupsLogic); + + const handleLinkClick = () => { + fetchGroupUsers(groupId); + onButtonClick(); + }; + + const toggleLink = ( + + + {numOptions} + + ); + + return ( + : undefined} + className="user-group-source--additional__wrap" + button={toggleLink} + closePopover={closePopover} + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx new file mode 100644 index 0000000000000..659f7f209e498 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_source_prioritization.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ChangeEvent, MouseEvent } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiRange, + EuiPanel, + EuiSpacer, + EuiTable, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableBody, + EuiTableRow, + EuiTableRowCell, +} from '@elastic/eui'; + +import { Loading } from '../../../components/shared/loading'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { SourceIcon } from '../../../components/shared/source_icon'; + +import { GroupLogic } from '../group_logic'; + +import { IContentSource } from '../../../types'; + +const HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.headerTitle', + { + defaultMessage: 'Shared content source prioritization', + } +); +const HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.headerDescription', + { + defaultMessage: 'Calibrate relative document importance across group content sources.', + } +); +const HEADER_ACTION_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.headerActionText', + { + defaultMessage: 'Save', + } +); +const ZERO_STATE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.zeroStateTitle', + { + defaultMessage: 'No sources are shared with this group', + } +); +const ZERO_STATE_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.zeroStateButtonText', + { + defaultMessage: 'Add shared content sources', + } +); +const SOURCE_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.sourceTableHeader', + { + defaultMessage: 'Source', + } +); +const PRIORITY_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.priorityTableHeader', + { + defaultMessage: 'Relevance Priority', + } +); + +export const GroupSourcePrioritization: React.FC = () => { + const { updatePriority, saveGroupSourcePrioritization, showSharedSourcesModal } = useActions( + GroupLogic + ); + + const { + group: { contentSources, name: groupName }, + dataLoading, + activeSourcePriorities, + groupPrioritiesUnchanged, + } = useValues(GroupLogic); + + if (dataLoading) return ; + + const headerAction = ( + + {HEADER_ACTION_TEXT} + + ); + const handleSliderChange = ( + id: string, + e: ChangeEvent | MouseEvent + ) => updatePriority(id, Number((e.target as HTMLInputElement).value)); + const hasSources = contentSources.length > 0; + + const zeroState = ( + + + {ZERO_STATE_TITLE}} + body={ + <> + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourceProioritization.zeroStateBody', + { + defaultMessage: + 'Share two or more sources with {groupName} to customize source prioritization.', + values: { groupName }, + } + )} + + } + actions={{ZERO_STATE_BUTTON_TEXT}} + /> + + + ); + + const sourceTable = ( + + + {SOURCE_TABLE_HEADER} + {PRIORITY_TABLE_HEADER} + + + {contentSources.map(({ id, name, serviceType }: IContentSource) => ( + + + + + + + + {name} + + + + + + + | MouseEvent) => + handleSliderChange(id, e) + } + /> + + +
+ {activeSourcePriorities[id]} +
+
+
+
+
+ ))} +
+
+ ); + + return ( + <> + + {hasSources ? sourceTable : zeroState} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.tsx new file mode 100644 index 0000000000000..7ae9856834443 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sources.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; + +import { SourceIcon } from '../../../components/shared/source_icon'; +import { MAX_TABLE_ROW_ICONS } from '../../../constants'; + +import { IContentSource } from '../../../types'; + +import { GroupRowSourcesDropdown } from './group_row_sources_dropdown'; + +interface IGroupSourcesProps { + groupSources: IContentSource[]; +} + +export const GroupSources: React.FC = ({ groupSources }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const closePopover = () => setPopoverOpen(false); + const togglePopover = () => setPopoverOpen(!popoverOpen); + const hiddenSources = [...groupSources]; + const visibleSources = hiddenSources.splice(0, MAX_TABLE_ROW_ICONS); + + return ( + <> + {visibleSources.map((source, index) => ( + + ))} + {hiddenSources.length > 0 && ( + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.tsx new file mode 100644 index 0000000000000..db8d390acce51 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_sub_nav.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { GroupLogic } from '../group_logic'; + +import { SideNavLink } from '../../../../shared/layout'; + +import { getGroupPath, getGroupSourcePrioritizationPath } from '../../../routes'; + +export const GroupSubNav: React.FC = () => { + const { + group: { id }, + } = useValues(GroupLogic); + + if (!id) return null; + + return ( + <> + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.groups.groupOverview', { + defaultMessage: 'Overview', + })} + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.groups.sourcePrioritization', { + defaultMessage: 'Source Prioritization', + })} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.tsx new file mode 100644 index 0000000000000..6ce4370ccb8d1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; + +import { UserIcon } from '../../../components/shared/user_icon'; +import { MAX_TABLE_ROW_ICONS } from '../../../constants'; + +import { IUser } from '../../../types'; + +import { GroupRowUsersDropdown } from './group_row_users_dropdown'; + +interface IGroupUsersProps { + groupUsers: IUser[]; + usersCount: number; + groupId: string; +} + +export const GroupUsers: React.FC = ({ groupUsers, usersCount, groupId }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const closePopover = () => setPopoverOpen(false); + const togglePopover = () => setPopoverOpen(!popoverOpen); + const hiddenUsers = [...groupUsers]; + const visibleUsers = hiddenUsers.splice(0, MAX_TABLE_ROW_ICONS); + + return ( + <> + {visibleUsers.map((user, index) => ( + + ))} + {hiddenUsers.length > 0 && ( + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx new file mode 100644 index 0000000000000..5ab71056aba7e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_users_table.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; + +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiTable, EuiTableBody, EuiTablePagination } from '@elastic/eui'; +import { Pager } from '@elastic/eui'; + +import { IUser } from '../../../types'; + +import { TableHeader } from '../../../../shared/table_header'; +import { UserRow } from '../../../components/shared/user_row'; + +import { AppLogic } from '../../../app_logic'; +import { GroupLogic } from '../group_logic'; + +const USERS_PER_PAGE = 10; +const USERNAME_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.usernameTableHeader', + { + defaultMessage: 'Username', + } +); +const EMAIL_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsUsersTable.emailTableHeader', + { + defaultMessage: 'Email', + } +); + +export const GroupUsersTable: React.FC = () => { + const { isFederatedAuth } = useValues(AppLogic); + const { + group: { users }, + } = useValues(GroupLogic); + const headerItems = [USERNAME_TABLE_HEADER]; + if (!isFederatedAuth) { + headerItems.push(EMAIL_TABLE_HEADER); + } + + const [firstItem, setFirstItem] = useState(0); + const [lastItem, setLastItem] = useState(USERS_PER_PAGE - 1); + const [currentPage, setCurrentPage] = useState(0); + + const numUsers = users.length; + const pager = new Pager(numUsers, USERS_PER_PAGE); + + const onChangePage = (pageIndex: number) => { + pager.goToPageIndex(pageIndex); + setFirstItem(pager.firstItemIndex); + setLastItem(pager.lastItemIndex); + setCurrentPage(pager.getCurrentPageIndex()); + }; + + const pagination = ( + + ); + + return ( + <> + + + + {users.slice(firstItem, lastItem + 1).map((user: IUser) => ( + + ))} + + + {numUsers > USERS_PER_PAGE && pagination} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx new file mode 100644 index 0000000000000..896a80e642be4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/groups_table.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiSpacer, + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, +} from '@elastic/eui'; + +import { TablePaginationBar } from '../../../components/shared/table_pagination_bar'; + +import { AppLogic } from '../../../app_logic'; +import { GroupsLogic } from '../groups_logic'; +import { GroupRow } from './group_row'; + +import { ClearFiltersLink } from './clear_filters_link'; + +const GROUP_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.groupTableHeader', + { + defaultMessage: 'Group', + } +); +const SOURCES_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.sourcesTableHeader', + { + defaultMessage: 'Content sources', + } +); +const USERS_TABLE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupsTable.usersTableHeader', + { + defaultMessage: 'Users', + } +); + +export const GroupsTable: React.FC<{}> = () => { + const { setActivePage } = useActions(GroupsLogic); + const { + groupsMeta: { + page: { total_pages: totalPages, total_results: totalItems, current: activePage }, + }, + groups, + hasFiltersSet, + } = useValues(GroupsLogic); + const { isFederatedAuth } = useValues(AppLogic); + + const clearFiltersLink = hasFiltersSet ? : undefined; + + const paginationOptions = { + itemLabel: 'Groups', + totalPages, + totalItems, + activePage, + clearFiltersLink, + onChangePage: (page: number) => { + // EUI component starts page at 0. API starts at 1. + setActivePage(page + 1); + }, + }; + + const showPagination = totalPages > 1; + + return ( + <> + {showPagination ? : clearFiltersLink} + + + + {GROUP_TABLE_HEADER} + {SOURCES_TABLE_HEADER} + {!isFederatedAuth && {USERS_TABLE_HEADER}} + + + + {groups.map((group, index) => ( + + ))} + + + + {showPagination && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.tsx new file mode 100644 index 0000000000000..8a384cfd5a91a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/manage_users_modal.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { GroupLogic } from '../group_logic'; +import { GroupsLogic } from '../groups_logic'; + +import { FilterableUsersList } from './filterable_users_list'; +import { GroupManagerModal } from './group_manager_modal'; + +const MODAL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.usersModalLabel', + { + defaultMessage: 'users', + } +); + +export const ManageUsersModal: React.FC = () => { + const { + addGroupUser, + removeGroupUser, + selectAllUsers, + hideManageUsersModal, + saveGroupUsers, + } = useActions(GroupLogic); + + const { selectedGroupUsers } = useValues(GroupLogic); + const { users } = useValues(GroupsLogic); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.tsx new file mode 100644 index 0000000000000..1bc72f99d7be8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/shared_sources_modal.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { GroupLogic } from '../group_logic'; +import { GroupsLogic } from '../groups_logic'; + +import { GroupManagerModal } from './group_manager_modal'; +import { SourcesList } from './sources_list'; + +const MODAL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.sourcesModalLabel', + { + defaultMessage: 'shared content sources', + } +); + +export const SharedSourcesModal: React.FC = () => { + const { + addGroupSource, + selectAllSources, + hideSharedSourcesModal, + removeGroupSource, + saveGroupSources, + } = useActions(GroupLogic); + + const { selectedGroupSources, group } = useValues(GroupLogic); + + const { contentSources } = useValues(GroupsLogic); + + return ( + + <> +

+ {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.sourcesModalTitle', { + defaultMessage: 'Select content sources to share with {groupName}', + values: { groupName: group.name }, + })} +

+ + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.tsx new file mode 100644 index 0000000000000..f6677670f8a88 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/source_option_item.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { TruncatedContent } from '../../../../shared/truncate'; + +import { SourceIcon } from '../../../components/shared/source_icon'; +import { IContentSource } from '../../../types'; + +const MAX_LENGTH = 28; + +interface ISourceOptionItemProps { + source: IContentSource; +} + +export const SourceOptionItem: React.FC = ({ source }) => ( + + + + + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.tsx new file mode 100644 index 0000000000000..e8f9027d98e0d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/sources_list.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFilterSelectItem } from '@elastic/eui'; + +import { IContentSource } from '../../../types'; + +import { SourceOptionItem } from './source_option_item'; + +interface ISourcesListProps { + contentSources: IContentSource[]; + filteredSources: string[]; + addFilteredSource(sourceId: string): void; + removeFilteredSource(sourceId: string): void; +} + +export const SourcesList: React.FC = ({ + contentSources, + filteredSources, + addFilteredSource, + removeFilteredSource, +}) => { + const sourceIds = contentSources.map(({ id }) => id); + const sources = sourceIds.map((sourceId, index) => { + const checked = filteredSources.indexOf(sourceId) > -1 ? 'on' : undefined; + const handleClick = () => + checked ? removeFilteredSource(sourceId) : addFilteredSource(sourceId); + return ( + + id === sourceId)[0]} /> + + ); + }); + + return
{sources}
; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.tsx new file mode 100644 index 0000000000000..220c33ca86ddd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_sources_dropdown.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFilterButton, EuiFilterGroup, EuiPopover } from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; +import { SourcesList } from './sources_list'; + +const FILTER_SOURCES_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.filterSources.buttonText', + { + defaultMessage: 'Sources', + } +); + +export const TableFilterSourcesDropdown: React.FC = () => { + const { + addFilteredSource, + removeFilteredSource, + toggleFilterSourcesDropdown, + closeFilterSourcesDropdown, + } = useActions(GroupsLogic); + const { contentSources, filterSourcesDropdownOpen, filteredSources } = useValues(GroupsLogic); + + const sourceIds = contentSources.map(({ id }) => id); + + const filterButton = ( + 0} + numActiveFilters={filteredSources.length} + > + {FILTER_SOURCES_BUTTON_TEXT} + + ); + + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx new file mode 100644 index 0000000000000..6345c4378418f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filter_users_dropdown.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFilterButton } from '@elastic/eui'; + +import { GroupsLogic } from '../groups_logic'; +import { FilterableUsersPopover } from './filterable_users_popover'; + +const FILTER_USERS_BUTTON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.filterUsers.buttonText', + { + defaultMessage: 'Users', + } +); + +export const TableFilterUsersDropdown: React.FC<{}> = () => { + const { closeFilterUsersDropdown, toggleFilterUsersDropdown } = useActions(GroupsLogic); + const { filteredUsers, filterUsersDropdownOpen, users } = useValues(GroupsLogic); + + const filterButton = ( + 0} + numActiveFilters={filteredUsers.length} + > + {FILTER_USERS_BUTTON_TEXT} + + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.tsx new file mode 100644 index 0000000000000..d11af030822bf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/table_filters.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ChangeEvent } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { AppLogic } from '../../../app_logic'; +import { GroupsLogic } from '../groups_logic'; + +import { TableFilterSourcesDropdown } from './table_filter_sources_dropdown'; +import { TableFilterUsersDropdown } from './table_filter_users_dropdown'; + +const FILTER_GROUPS_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.filterGroups.placeholder', + { + defaultMessage: 'Filter groups by name...', + } +); + +export const TableFilters: React.FC = () => { + const { setFilterValue } = useActions(GroupsLogic); + const { filterValue } = useValues(GroupsLogic); + const { isFederatedAuth } = useValues(AppLogic); + + const handleSearchChange = (e: ChangeEvent) => setFilterValue(e.target.value); + + return ( + + + + + + + + + + {!isFederatedAuth && ( + + + + )} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.tsx new file mode 100644 index 0000000000000..8eb199d67cf92 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/user_option_item.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { UserIcon } from '../../../components/shared/user_icon'; +import { IUser } from '../../../types'; + +interface IUserOptionItemProps { + user: IUser; +} + +export const UserOptionItem: React.FC = ({ user }) => ( + + + + + {user.name || user.email} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts new file mode 100644 index 0000000000000..1ce0fe53726d4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.ts @@ -0,0 +1,388 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kea, MakeLogicType } from 'kea'; +import { isEqual } from 'lodash'; +import { History } from 'history'; +import { i18n } from '@kbn/i18n'; + +import { HttpLogic } from '../../../shared/http'; +import { KibanaLogic } from '../../../shared/kibana'; + +import { + FlashMessagesLogic, + flashAPIErrors, + setSuccessMessage, + setQueuedSuccessMessage, +} from '../../../shared/flash_messages'; +import { GROUPS_PATH } from '../../routes'; + +import { IContentSourceDetails, IGroupDetails, IUser, ISourcePriority } from '../../types'; + +export const MAX_NAME_LENGTH = 40; + +export interface IGroupActions { + onInitializeGroup(group: IGroupDetails): IGroupDetails; + onGroupNameChanged(group: IGroupDetails): IGroupDetails; + onGroupPrioritiesChanged(group: IGroupDetails): IGroupDetails; + onGroupNameInputChange(groupName: string): string; + addGroupSource(sourceId: string): string; + removeGroupSource(sourceId: string): string; + addGroupUser(userId: string): string; + removeGroupUser(userId: string): string; + onGroupSourcesSaved(group: IGroupDetails): IGroupDetails; + onGroupUsersSaved(group: IGroupDetails): IGroupDetails; + setGroupModalErrors(errors: string[]): string[]; + hideSharedSourcesModal(group: IGroupDetails): IGroupDetails; + hideManageUsersModal(group: IGroupDetails): IGroupDetails; + selectAllSources(contentSources: IContentSourceDetails[]): IContentSourceDetails[]; + selectAllUsers(users: IUser[]): IUser[]; + updatePriority(id: string, boost: number): { id: string; boost: number }; + resetGroup(): void; + showConfirmDeleteModal(): void; + hideConfirmDeleteModal(): void; + showSharedSourcesModal(): void; + showManageUsersModal(): void; + resetFlashMessages(): void; + initializeGroup(groupId: string): { groupId: string }; + deleteGroup(): void; + updateGroupName(): void; + saveGroupSources(): void; + saveGroupUsers(): void; + saveGroupSourcePrioritization(): void; +} + +export interface IGroupValues { + contentSources: IContentSourceDetails[]; + users: IUser[]; + group: IGroupDetails; + dataLoading: boolean; + manageUsersModalVisible: boolean; + managerModalFormErrors: string[]; + sharedSourcesModalModalVisible: boolean; + confirmDeleteModalVisible: boolean; + groupNameInputValue: string; + selectedGroupSources: string[]; + selectedGroupUsers: string[]; + groupPrioritiesUnchanged: boolean; + activeSourcePriorities: ISourcePriority; + cachedSourcePriorities: ISourcePriority; +} + +export const GroupLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'group'], + actions: { + onInitializeGroup: (group: IGroupDetails) => group, + onGroupNameChanged: (group: IGroupDetails) => group, + onGroupPrioritiesChanged: (group: IGroupDetails) => group, + onGroupNameInputChange: (groupName: string) => groupName, + addGroupSource: (sourceId: string) => sourceId, + removeGroupSource: (sourceId: string) => sourceId, + addGroupUser: (userId: string) => userId, + removeGroupUser: (userId: string) => userId, + onGroupSourcesSaved: (group: IGroupDetails) => group, + onGroupUsersSaved: (group: IGroupDetails) => group, + setGroupModalErrors: (errors: string[]) => errors, + hideSharedSourcesModal: (group: IGroupDetails) => group, + hideManageUsersModal: (group: IGroupDetails) => group, + selectAllSources: (contentSources: IContentSourceDetails[]) => contentSources, + selectAllUsers: (users: IUser[]) => users, + updatePriority: (id: string, boost: number) => ({ id, boost }), + resetGroup: () => true, + showConfirmDeleteModal: () => true, + hideConfirmDeleteModal: () => true, + showSharedSourcesModal: () => true, + showManageUsersModal: () => true, + resetFlashMessages: () => true, + initializeGroup: (groupId: string, history: History) => ({ groupId, history }), + deleteGroup: () => true, + updateGroupName: () => true, + saveGroupSources: () => true, + saveGroupUsers: () => true, + saveGroupSourcePrioritization: () => true, + }, + reducers: { + group: [ + {} as IGroupDetails, + { + onInitializeGroup: (_, group) => group, + onGroupNameChanged: (_, group) => group, + onGroupSourcesSaved: (_, group) => group, + onGroupUsersSaved: (_, group) => group, + resetGroup: () => ({} as IGroupDetails), + }, + ], + dataLoading: [ + true, + { + onInitializeGroup: () => false, + onGroupPrioritiesChanged: () => false, + resetGroup: () => true, + }, + ], + manageUsersModalVisible: [ + false, + { + showManageUsersModal: () => true, + hideManageUsersModal: () => false, + onGroupUsersSaved: () => false, + }, + ], + managerModalFormErrors: [ + [], + { + setGroupModalErrors: (_, errors) => errors, + hideManageUsersModal: () => [], + }, + ], + sharedSourcesModalModalVisible: [ + false, + { + showSharedSourcesModal: () => true, + hideSharedSourcesModal: () => false, + onGroupSourcesSaved: () => false, + }, + ], + confirmDeleteModalVisible: [ + false, + { + showConfirmDeleteModal: () => true, + hideConfirmDeleteModal: () => false, + }, + ], + groupNameInputValue: [ + '', + { + onInitializeGroup: (_, { name }) => name, + onGroupNameChanged: (_, { name }) => name, + onGroupNameInputChange: (_, name) => name, + }, + ], + selectedGroupSources: [ + [], + { + onInitializeGroup: (_, { contentSources }) => contentSources.map(({ id }) => id), + onGroupSourcesSaved: (_, { contentSources }) => contentSources.map(({ id }) => id), + selectAllSources: (_, contentSources) => contentSources.map(({ id }) => id), + hideSharedSourcesModal: (_, { contentSources }) => contentSources.map(({ id }) => id), + addGroupSource: (state, sourceId) => [...state, sourceId].sort(), + removeGroupSource: (state, sourceId) => state.filter((id) => id !== sourceId), + }, + ], + selectedGroupUsers: [ + [], + { + onInitializeGroup: (_, { users }) => users.map(({ id }) => id), + onGroupUsersSaved: (_, { users }) => users.map(({ id }) => id), + selectAllUsers: (_, users) => users.map(({ id }) => id), + hideManageUsersModal: (_, { users }) => users.map(({ id }) => id), + addGroupUser: (state, userId) => [...state, userId].sort(), + removeGroupUser: (state, userId) => state.filter((id) => id !== userId), + }, + ], + cachedSourcePriorities: [ + {}, + { + onInitializeGroup: (_, { contentSources }) => mapPriorities(contentSources), + onGroupPrioritiesChanged: (_, { contentSources }) => mapPriorities(contentSources), + onGroupSourcesSaved: (_, { contentSources }) => mapPriorities(contentSources), + }, + ], + activeSourcePriorities: [ + {}, + { + onInitializeGroup: (_, { contentSources }) => mapPriorities(contentSources), + onGroupPrioritiesChanged: (_, { contentSources }) => mapPriorities(contentSources), + onGroupSourcesSaved: (_, { contentSources }) => mapPriorities(contentSources), + updatePriority: (state, { id, boost }) => { + const updated = { ...state }; + updated[id] = boost; + return updated; + }, + }, + ], + }, + selectors: ({ selectors }) => ({ + groupPrioritiesUnchanged: [ + () => [selectors.cachedSourcePriorities, selectors.activeSourcePriorities], + (cached, active) => isEqual(cached, active), + ], + }), + listeners: ({ actions, values }) => ({ + initializeGroup: async ({ groupId }) => { + try { + const response = await HttpLogic.values.http.get(`/api/workplace_search/groups/${groupId}`); + actions.onInitializeGroup(response); + } catch (e) { + const NOT_FOUND_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupNotFound', + { + defaultMessage: 'Unable to find group with ID: "{groupId}".', + values: { groupId }, + } + ); + + const error = e.response.status === 404 ? NOT_FOUND_MESSAGE : e; + + FlashMessagesLogic.actions.setQueuedMessages({ + type: 'error', + message: error, + }); + + KibanaLogic.values.navigateToUrl(GROUPS_PATH); + } + }, + deleteGroup: async () => { + const { + group: { id, name }, + } = values; + try { + await HttpLogic.values.http.delete(`/api/workplace_search/groups/${id}`); + const GROUP_DELETED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupDeleted', + { + defaultMessage: 'Group "{groupName}" was successfully deleted.', + values: { groupName: name }, + } + ); + + setQueuedSuccessMessage(GROUP_DELETED_MESSAGE); + KibanaLogic.values.navigateToUrl(GROUPS_PATH); + } catch (e) { + flashAPIErrors(e); + } + }, + updateGroupName: async () => { + const { + group: { id }, + groupNameInputValue, + } = values; + + try { + const response = await HttpLogic.values.http.put(`/api/workplace_search/groups/${id}`, { + body: JSON.stringify({ group: { name: groupNameInputValue } }), + }); + actions.onGroupNameChanged(response); + + const GROUP_RENAMED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupRenamed', + { + defaultMessage: 'Successfully renamed this group to "{groupName}".', + values: { groupName: response.name }, + } + ); + setSuccessMessage(GROUP_RENAMED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + saveGroupSources: async () => { + const { + group: { id }, + selectedGroupSources, + } = values; + + try { + const response = await HttpLogic.values.http.post( + `/api/workplace_search/groups/${id}/share`, + { + body: JSON.stringify({ content_source_ids: selectedGroupSources }), + } + ); + actions.onGroupSourcesSaved(response); + const GROUP_SOURCES_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupSourcesUpdated', + { + defaultMessage: 'Successfully updated shared content sources.', + } + ); + setSuccessMessage(GROUP_SOURCES_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + saveGroupUsers: async () => { + const { + group: { id }, + selectedGroupUsers, + } = values; + + try { + const response = await HttpLogic.values.http.post( + `/api/workplace_search/groups/${id}/assign`, + { + body: JSON.stringify({ user_ids: selectedGroupUsers }), + } + ); + actions.onGroupUsersSaved(response); + const GROUP_USERS_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupUsersUpdated', + { + defaultMessage: 'Successfully updated the users of this group', + } + ); + setSuccessMessage(GROUP_USERS_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + saveGroupSourcePrioritization: async () => { + const { + group: { id }, + activeSourcePriorities, + } = values; + + // server expects an array of id, value for each boost. + // example: [['123abc', 7], ['122abv', 1]] + const boosts = [] as Array>; + Object.keys(activeSourcePriorities).forEach((k: string) => + boosts.push([k, Number(activeSourcePriorities[k])]) + ); + + try { + const response = await HttpLogic.values.http.put( + `/api/workplace_search/groups/${id}/boosts`, + { + body: JSON.stringify({ content_source_boosts: boosts }), + } + ); + + const GROUP_PRIORITIZATION_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.groupPrioritizationUpdated', + { + defaultMessage: 'Successfully updated shared source prioritization', + } + ); + + setSuccessMessage(GROUP_PRIORITIZATION_UPDATED_MESSAGE); + actions.onGroupPrioritiesChanged(response); + } catch (e) { + flashAPIErrors(e); + } + }, + showConfirmDeleteModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + showManageUsersModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + showSharedSourcesModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + resetFlashMessages: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + }), +}); + +const mapPriorities = (contentSources: IContentSourceDetails[]): ISourcePriority => { + const prioritiesMap = {} as ISourcePriority; + contentSources.forEach(({ id, boost }) => { + prioritiesMap[id] = boost; + }); + + return prioritiesMap; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx new file mode 100644 index 0000000000000..e5779a96b4687 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_router.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; +import { Route, Switch, useParams } from 'react-router-dom'; + +import { FlashMessages, FlashMessagesLogic } from '../../../shared/flash_messages'; +import { GROUP_SOURCE_PRIORITIZATION_PATH, GROUP_PATH } from '../../routes'; +import { GroupLogic } from './group_logic'; + +import { ManageUsersModal } from './components/manage_users_modal'; +import { SharedSourcesModal } from './components/shared_sources_modal'; + +import { GroupOverview } from './components/group_overview'; +import { GroupSourcePrioritization } from './components/group_source_prioritization'; + +export const GroupRouter: React.FC = () => { + const { groupId } = useParams() as { groupId: string }; + + const { messages } = useValues(FlashMessagesLogic); + const { initializeGroup, resetGroup } = useActions(GroupLogic); + const { sharedSourcesModalModalVisible, manageUsersModalVisible } = useValues(GroupLogic); + + const hasMessages = messages.length > 0; + + useEffect(() => { + initializeGroup(groupId); + return resetGroup; + }, []); + + return ( + <> + {hasMessages && } + + + + + {sharedSourcesModalModalVisible && } + {manageUsersModalVisible && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.scss new file mode 100644 index 0000000000000..fbd4e6f87d19b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.scss @@ -0,0 +1,101 @@ +.groups-table { + background-color: transparent; +} + +.user-groups-header { + display: flex; + padding: 0 1.5rem; + margin-bottom: 1rem; + font-size: .875rem; + font-weight: 500; + color: $euiColorDarkShade; + + &__title { + flex: 1; + } + + &__sources, + &__accounts { + width: 25%; + } +} + +.user-group { + display: flex; + height: 80px; + background: $euiColorLightestShade; + color: $euiColorDarkestShade; + border-radius: 6px; + align-items: center; + padding: 0 1.5rem; + position: relative; + margin-bottom: 1rem; + &:hover { + background: $euiColorEmptyShade; + color: $euiColorFullShade; + box-shadow: + inset 0 0 0 1px $euiColorLightShade, + 0 2px 4px rgba(black, .05); + } + + &:after { + content: ''; + width: 8px; + height: 8px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + opacity: .5; + position: absolute; + transform: translateY(-50%) rotate(-45deg); + top: 50%; + right: 1.5rem; + } + + &__sources, + &__accounts { + display: flex; + align-items: center; + } + + &__item { + pointer-events: none; + } +} + +.user-group-source, +.user-group-account { + width: 30px; + height: 30px; + overflow: hidden; + margin-right: 4px; + position: relative; + display: flex; + justify-content: center; + align-items: center; + + &--additional { + font-size: .875rem; + margin-left: .5rem; + opacity: .75; + font-weight: 500; + + &__wrap { + border: none; + box-shadow: none; + } + } + + img { + max-width: 100%; + } +} + +.user-groups-filters { + &__search-bar { + min-width: 260px!important; + } + + &__filter-sources { + min-width: 130px!important; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx new file mode 100644 index 0000000000000..ab5c6884d64df --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import { EuiButton as EuiLinkButton } from '../../../shared/react_router_helpers'; +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; + +import { AppLogic } from '../../app_logic'; + +import { Loading } from '../../components/shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { getGroupPath, USERS_PATH } from '../../routes'; + +import { useDidUpdateEffect } from '../../../shared/use_did_update_effect'; +import { FlashMessages, FlashMessagesLogic } from '../../../shared/flash_messages'; + +import { GroupsLogic } from './groups_logic'; + +import { AddGroupModal } from './components/add_group_modal'; +import { ClearFiltersLink } from './components/clear_filters_link'; +import { GroupsTable } from './components/groups_table'; +import { TableFilters } from './components/table_filters'; + +export const Groups: React.FC = () => { + const { messages } = useValues(FlashMessagesLogic); + + const { getSearchResults, openNewGroupModal, resetGroups } = useActions(GroupsLogic); + const { + groupsDataLoading, + newGroupModalOpen, + newGroup, + groupListLoading, + hasFiltersSet, + groupsMeta: { + page: { current: activePage, total_results: numGroups }, + }, + filteredSources, + filteredUsers, + filterValue, + } = useValues(GroupsLogic); + + const { isFederatedAuth } = useValues(AppLogic); + + const hasMessages = messages.length > 0; + + useEffect(() => { + getSearchResults(true); + return resetGroups; + }, [filteredSources, filteredUsers, filterValue]); + + // Because the initial search happens above, we want to skip the initial search and use the custom hook to do so. + useDidUpdateEffect(() => { + getSearchResults(); + }, [activePage]); + + if (groupsDataLoading) { + return ; + } + + if (newGroup && hasMessages) { + messages[0].description = ( + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.newGroup.action', { + defaultMessage: 'Manage Group', + })} + + ); + } + + const clearFilters = hasFiltersSet && ; + const inviteUsersButton = !isFederatedAuth ? ( + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.inviteUsers.action', { + defaultMessage: 'Invite users', + })} + + ) : null; + + const headerAction = ( + + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.groups.addGroupForm.action', { + defaultMessage: 'Create a group', + })} + + + {inviteUsersButton} + + ); + + const noResults = ( + + + {groupListLoading ? ( + + ) : ( + <> + {clearFilters} +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.searchResults.notFoound', + { + defaultMessage: 'No results found.', + } + )} +

+ + )} +
+
+ ); + + return ( + <> + + + + + + + + {numGroups > 0 && !groupListLoading ? : noResults} + {newGroupModalOpen && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts new file mode 100644 index 0000000000000..35d4387b4cf3d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts @@ -0,0 +1,351 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kea, MakeLogicType } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { HttpLogic } from '../../../shared/http'; + +import { + FlashMessagesLogic, + flashAPIErrors, + setSuccessMessage, +} from '../../../shared/flash_messages'; + +import { IContentSource, IGroup, IUser } from '../../types'; + +import { JSON_HEADER as headers } from '../../../../../common/constants'; +import { DEFAULT_META } from '../../../shared/constants'; +import { IMeta } from '../../../../../common/types'; + +export const MAX_NAME_LENGTH = 40; + +interface IGroupsServerData { + contentSources: IContentSource[]; + users: IUser[]; +} + +interface IGroupsSearchResponse { + results: IGroup[]; + meta: IMeta; +} + +export interface IGroupsActions { + onInitializeGroups(data: IGroupsServerData): IGroupsServerData; + setSearchResults(data: IGroupsSearchResponse): IGroupsSearchResponse; + addFilteredSource(sourceId: string): string; + removeFilteredSource(sourceId: string): string; + addFilteredUser(userId: string): string; + removeFilteredUser(userId: string): string; + setGroupUsers(allGroupUsers: IUser[]): IUser[]; + setAllGroupLoading(allGroupUsersLoading: boolean): boolean; + setFilterValue(filterValue: string): string; + setActivePage(activePage: number): number; + setNewGroupName(newGroupName: string): string; + setNewGroup(newGroup: IGroup): IGroup; + setNewGroupFormErrors(errors: string[]): string[]; + openNewGroupModal(): void; + closeNewGroupModal(): void; + closeFilterSourcesDropdown(): void; + closeFilterUsersDropdown(): void; + toggleFilterSourcesDropdown(): void; + toggleFilterUsersDropdown(): void; + setGroupsLoading(): void; + resetGroupsFilters(): void; + resetGroups(): void; + initializeGroups(): void; + getSearchResults(resetPagination?: boolean): { resetPagination: boolean | undefined }; + fetchGroupUsers(groupId: string): { groupId: string }; + saveNewGroup(): void; +} + +export interface IGroupsValues { + groups: IGroup[]; + contentSources: IContentSource[]; + users: IUser[]; + groupsDataLoading: boolean; + groupListLoading: boolean; + newGroupModalOpen: boolean; + newGroupName: string; + newGroup: IGroup | null; + newGroupNameErrors: string[]; + filterSourcesDropdownOpen: boolean; + filteredSources: string[]; + filterUsersDropdownOpen: boolean; + filteredUsers: string[]; + allGroupUsersLoading: boolean; + allGroupUsers: IUser[]; + filterValue: string; + groupsMeta: IMeta; + hasFiltersSet: boolean; +} + +export const GroupsLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'groups'], + actions: { + onInitializeGroups: (data: IGroupsServerData) => data, + setSearchResults: (data: IGroupsSearchResponse) => data, + addFilteredSource: (sourceId: string) => sourceId, + removeFilteredSource: (sourceId: string) => sourceId, + addFilteredUser: (userId: string) => userId, + removeFilteredUser: (userId: string) => userId, + setGroupUsers: (allGroupUsers: IUser[]) => allGroupUsers, + setAllGroupLoading: (allGroupUsersLoading: boolean) => allGroupUsersLoading, + setFilterValue: (filterValue: string) => filterValue, + setActivePage: (activePage: number) => activePage, + setNewGroupName: (newGroupName: string) => newGroupName, + setNewGroup: (newGroup: IGroup) => newGroup, + setNewGroupFormErrors: (errors: string[]) => errors, + openNewGroupModal: () => true, + closeNewGroupModal: () => true, + closeFilterSourcesDropdown: () => true, + closeFilterUsersDropdown: () => true, + toggleFilterSourcesDropdown: () => true, + toggleFilterUsersDropdown: () => true, + setGroupsLoading: () => true, + resetGroupsFilters: () => true, + resetGroups: () => true, + initializeGroups: () => true, + getSearchResults: (resetPagination?: boolean) => ({ resetPagination }), + fetchGroupUsers: (groupId: string) => ({ groupId }), + saveNewGroup: () => true, + }, + reducers: { + groups: [ + [] as IGroup[], + { + setSearchResults: (_, { results }) => results, + }, + ], + contentSources: [ + [], + { + onInitializeGroups: (_, { contentSources }) => contentSources, + }, + ], + users: [ + [], + { + onInitializeGroups: (_, { users }) => users, + }, + ], + groupsDataLoading: [ + true, + { + onInitializeGroups: () => false, + }, + ], + groupListLoading: [ + true, + { + setSearchResults: () => false, + setGroupsLoading: () => true, + }, + ], + newGroupModalOpen: [ + false, + { + openNewGroupModal: () => true, + closeNewGroupModal: () => false, + setNewGroup: () => false, + }, + ], + newGroupName: [ + '', + { + setNewGroupName: (_, newGroupName) => newGroupName, + setSearchResults: () => '', + closeNewGroupModal: () => '', + }, + ], + newGroup: [ + null, + { + setNewGroup: (_, newGroup) => newGroup, + resetGroups: () => null, + openNewGroupModal: () => null, + }, + ], + newGroupNameErrors: [ + [], + { + setNewGroupFormErrors: (_, newGroupNameErrors) => newGroupNameErrors, + setNewGroup: () => [], + setNewGroupName: () => [], + closeNewGroupModal: () => [], + }, + ], + filterSourcesDropdownOpen: [ + false, + { + toggleFilterSourcesDropdown: (state) => !state, + closeFilterSourcesDropdown: () => false, + }, + ], + filteredSources: [ + [], + { + resetGroupsFilters: () => [], + setNewGroup: () => [], + addFilteredSource: (state, sourceId) => [...state, sourceId].sort(), + removeFilteredSource: (state, sourceId) => state.filter((id) => id !== sourceId), + }, + ], + filterUsersDropdownOpen: [ + false, + { + toggleFilterUsersDropdown: (state) => !state, + closeFilterUsersDropdown: () => false, + }, + ], + filteredUsers: [ + [], + { + resetGroupsFilters: () => [], + setNewGroup: () => [], + addFilteredUser: (state, userId) => [...state, userId].sort(), + removeFilteredUser: (state, userId) => state.filter((id) => id !== userId), + }, + ], + allGroupUsersLoading: [ + false, + { + setAllGroupLoading: (_, allGroupUsersLoading) => allGroupUsersLoading, + setGroupUsers: () => false, + }, + ], + allGroupUsers: [ + [], + { + setGroupUsers: (_, allGroupUsers) => allGroupUsers, + setAllGroupLoading: () => [], + }, + ], + filterValue: [ + '', + { + setFilterValue: (_, filterValue) => filterValue, + resetGroupsFilters: () => '', + }, + ], + groupsMeta: [ + DEFAULT_META, + { + resetGroupsFilters: () => DEFAULT_META, + setNewGroup: () => DEFAULT_META, + setSearchResults: (_, { meta }) => meta, + setActivePage: (state, activePage) => ({ + ...state, + page: { + ...state.page, + current: activePage, + }, + }), + }, + ], + }, + selectors: ({ selectors }) => ({ + hasFiltersSet: [ + () => [selectors.filteredUsers, selectors.filteredSources], + (filteredUsers, filteredSources) => filteredUsers.length > 0 || filteredSources.length > 0, + ], + }), + listeners: ({ actions, values }) => ({ + initializeGroups: async () => { + try { + const response = await HttpLogic.values.http.get('/api/workplace_search/groups'); + actions.onInitializeGroups(response); + } catch (e) { + flashAPIErrors(e); + } + }, + getSearchResults: async ({ resetPagination }, breakpoint) => { + // Debounce search results when typing + await breakpoint(300); + + actions.setGroupsLoading(); + + const { + groupsMeta: { + page: { current, size }, + }, + filterValue, + filteredSources, + filteredUsers, + } = values; + + // Is the user changes the query while on a different page, we want to start back over at 1. + const page = { + current: resetPagination ? 1 : current, + size, + }; + const search = { + query: filterValue, + content_source_ids: filteredSources, + user_ids: filteredUsers, + }; + + try { + const response = await HttpLogic.values.http.post('/api/workplace_search/groups/search', { + body: JSON.stringify({ + page, + search, + }), + headers, + }); + + actions.setSearchResults(response); + } catch (e) { + flashAPIErrors(e); + } + }, + fetchGroupUsers: async ({ groupId }) => { + actions.setAllGroupLoading(true); + try { + const response = await HttpLogic.values.http.get( + `/api/workplace_search/groups/${groupId}/group_users` + ); + actions.setGroupUsers(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveNewGroup: async () => { + try { + const response = await HttpLogic.values.http.post('/api/workplace_search/groups', { + body: JSON.stringify({ group_name: values.newGroupName }), + headers, + }); + actions.getSearchResults(true); + + const SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.newGroupSavedSuccess', + { + defaultMessage: 'Successfully created {groupName}', + values: { groupName: response.name }, + } + ); + + setSuccessMessage(SUCCESS_MESSAGE); + actions.setNewGroup(response); + } catch (e) { + flashAPIErrors(e); + } + }, + openNewGroupModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + resetGroupsFilters: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + toggleFilterSourcesDropdown: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + toggleFilterUsersDropdown: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx new file mode 100644 index 0000000000000..caa71d0d622f3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions } from 'kea'; + +import { Route, Switch } from 'react-router-dom'; + +import { GROUP_PATH, GROUPS_PATH } from '../../routes'; + +import { GroupsLogic } from './groups_logic'; + +import { GroupRouter } from './group_router'; +import { Groups } from './groups'; + +import './groups.scss'; + +export const GroupsRouter: React.FC = () => { + const { initializeGroups } = useActions(GroupsLogic); + + useEffect(() => { + initializeGroups(); + }, []); + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/index.ts new file mode 100644 index 0000000000000..79b5e39d2b27d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { GroupsRouter } from './groups_router'; +export { GroupsLogic } from './groups_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index f3736c0a21551..a712fbdd0dea6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -65,7 +65,7 @@ export const Overview: React.FC = () => { return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx index d632792f2a666..3d6d65fce2528 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx @@ -27,9 +27,11 @@ export const SetupGuide: React.FC = () => { elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/workplace-search/current/workplace-search-security.html#elasticsearch-native-realm" > diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index c63e3ff8ffb2b..dcc696f6d01e2 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -12,7 +12,7 @@ import { ConfigType } from '../'; import { IAccess } from './check_access'; import { IInitialAppData } from '../../common/types'; -import { stripTrailingSlash } from '../../common/strip_trailing_slash'; +import { stripTrailingSlash } from '../../common/strip_slashes'; interface IParams { request: KibanaRequest; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index a9bd03e8f97d4..43b0be8a5b438 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -45,6 +45,7 @@ import { registerCredentialsRoutes } from './routes/app_search/credentials'; import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry'; import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; import { registerWSOverviewRoute } from './routes/workplace_search/overview'; +import { registerWSGroupRoutes } from './routes/workplace_search/groups'; export interface PluginsSetup { usageCollection?: UsageCollectionSetup; @@ -129,6 +130,7 @@ export class EnterpriseSearchPlugin implements Plugin { registerEnginesRoute(dependencies); registerCredentialsRoutes(dependencies); registerWSOverviewRoute(dependencies); + registerWSGroupRoutes(dependencies); /** * Bootstrap the routes, saved objects, and collector for telemetry diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts new file mode 100644 index 0000000000000..21d08e5c8756b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouteDependencies } from '../../plugin'; + +import { IMeta } from '../../../common/types'; +import { IUser, IContentSource, IGroup } from '../../../common/types/workplace_search'; + +export function registerGroupsRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups', + hasValidData: (body: { users: IUser[]; contentSources: IContentSource[] }) => + typeof Array.isArray(body?.users) && typeof Array.isArray(body?.contentSources), + }) + ); + + router.post( + { + path: '/api/workplace_search/groups', + validate: { + body: schema.object({ + group_name: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups', + body: request.body, + hasValidData: (body: { created_at: string }) => typeof body?.created_at === 'string', + })(context, request, response); + } + ); +} + +export function registerSearchGroupsRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/search', + validate: { + body: schema.object({ + page: schema.object({ + current: schema.number(), + size: schema.number(), + }), + search: schema.object({ + query: schema.string(), + content_source_ids: schema.arrayOf(schema.string()), + user_ids: schema.arrayOf(schema.string()), + }), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups/search', + body: request.body, + hasValidData: (body: { results: IGroup[]; meta: IMeta }) => + typeof Array.isArray(body?.results) && + typeof body?.meta?.page?.total_results === 'number', + })(context, request, response); + } + ); +} + +export function registerGroupRoute({ router, enterpriseSearchRequestHandler }: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); + + router.put( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + group: schema.object({ + name: schema.string(), + }), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); + + router.delete( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + hasValidData: (body: { deleted: boolean }) => body?.deleted === true, + })(context, request, response); + } + ); +} + +export function registerGroupUsersRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups/{id}/group_users', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/group_users`, + hasValidData: (body: IUser[]) => typeof Array.isArray(body), + })(context, request, response); + } + ); +} + +export function registerShareGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/{id}/share', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + content_source_ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/share`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerAssignGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/{id}/assign', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + user_ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/assign`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerBoostsGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.put( + { + path: '/api/workplace_search/groups/{id}/boosts', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + content_source_boosts: schema.arrayOf( + schema.arrayOf(schema.oneOf([schema.string(), schema.number()])) + ), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/update_source_boosts`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerWSGroupRoutes(dependencies: IRouteDependencies) { + registerGroupsRoute(dependencies); + registerSearchGroupsRoute(dependencies); + registerGroupRoute(dependencies); + registerGroupUsersRoute(dependencies); + registerShareGroupRoute(dependencies); + registerAssignGroupRoute(dependencies); + registerBoostsGroupRoute(dependencies); +} diff --git a/x-pack/plugins/global_search/README.md b/x-pack/plugins/global_search/README.md index d47e0bd696fd8..db72f4901c778 100644 --- a/x-pack/plugins/global_search/README.md +++ b/x-pack/plugins/global_search/README.md @@ -39,7 +39,7 @@ Results from providers registered from the client-side `registerResultProvider` not be available when performing a search from the server-side. For this reason, prefer registering providers using the server-side API when possible. -Refer to the [RFC](rfcs/text/0011_global_search.md#result_provider_registration) for more details +Refer to the [RFC](../../../rfcs/text/0011_global_search.md#result_provider_registration) for more details ### Search completion cause diff --git a/x-pack/plugins/global_search/common/constants.ts b/x-pack/plugins/global_search/common/constants.ts new file mode 100644 index 0000000000000..423cf5f8be5a8 --- /dev/null +++ b/x-pack/plugins/global_search/common/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const defaultMaxProviderResults = 40; diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts index 68970b75ad975..62b347d925868 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -12,6 +12,7 @@ import { HttpStart } from 'src/core/public'; import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; +import { defaultMaxProviderResults } from '../../common/constants'; import { processProviderResult } from '../../common/process_result'; import { ILicenseChecker } from '../../common/license_checker'; import { GlobalSearchResultProvider } from '../types'; @@ -79,7 +80,6 @@ interface StartDeps { licenseChecker: ILicenseChecker; } -const defaultMaxProviderResults = 20; const mapToUndefined = () => undefined; /** @internal */ diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts index d79f3781c6bec..1897a24196cf1 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -11,6 +11,7 @@ import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; +import { defaultMaxProviderResults } from '../../common/constants'; import { ILicenseChecker } from '../../common/license_checker'; import { processProviderResult } from '../../common/process_result'; @@ -80,7 +81,6 @@ interface StartDeps { licenseChecker: ILicenseChecker; } -const defaultMaxProviderResults = 20; const mapToUndefined = () => undefined; /** @internal */ diff --git a/x-pack/plugins/global_search/tsconfig.json b/x-pack/plugins/global_search/tsconfig.json new file mode 100644 index 0000000000000..2d05328f445df --- /dev/null +++ b/x-pack/plugins/global_search/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + "common/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" } + ] +} + diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index 6fad3335c5efc..3c86c4e70e346 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -5,17 +5,19 @@ */ import React from 'react'; -import { wait } from '@testing-library/react'; -import { of } from 'rxjs'; +import { waitFor, act } from '@testing-library/react'; +import { ReactWrapper } from 'enzyme'; +import { of, BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { httpServiceMock, uiSettingsServiceMock } from '../../../../../src/core/public/mocks'; -import { - GlobalSearchBatchedResults, - GlobalSearchPluginStart, - GlobalSearchResult, -} from '../../../global_search/public'; +import { applicationServiceMock } from '../../../../../src/core/public/mocks'; +import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { globalSearchPluginMock } from '../../../global_search/public/mocks'; -import { SearchBar } from '../components/search_bar'; +import { SearchBar } from './search_bar'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => 'mockId', +})); type Result = { id: string; type: string } | string; @@ -38,30 +40,46 @@ const createBatch = (...results: Result[]): GlobalSearchBatchedResults => ({ results: results.map(createResult), }); -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: () => () => 'mockId', -})); - const getSelectableProps: any = (component: any) => component.find('EuiSelectable').props(); const getSearchProps: any = (component: any) => component.find('EuiFieldSearch').props(); describe('SearchBar', () => { - let searchService: GlobalSearchPluginStart; - let findSpy: jest.SpyInstance; - const http = httpServiceMock.createSetupContract({ basePath: '/test' }); - const basePathUrl = http.basePath.prepend('/plugins/globalSearchBar/assets/'); - const uiSettings = uiSettingsServiceMock.createStartContract(); - const darkMode = uiSettings.get('theme:darkMode'); + let searchService: ReturnType; + let applications: ReturnType; + const basePathUrl = '/plugins/globalSearchBar/assets/'; + const darkMode = false; + + let component: ReactWrapper; beforeEach(() => { + applications = applicationServiceMock.createStartContract(); searchService = globalSearchPluginMock.createStartContract(); - findSpy = jest.spyOn(searchService, 'find'); jest.useFakeTimers(); }); + const triggerFocus = () => { + component.find('input[data-test-subj="header-search"]').simulate('focus'); + }; + + const update = () => { + act(() => { + jest.runAllTimers(); + }); + component.update(); + }; + + const simulateTypeChar = async (text: string) => { + await waitFor(() => + getSearchProps(component).onKeyUpCapture({ currentTarget: { value: text } }) + ); + }; + + const getDisplayedOptionsLabel = () => { + return getSelectableProps(component).options.map((option: any) => option.label); + }; + it('correctly filters and sorts results', async () => { - const navigate = jest.fn(); - findSpy + searchService.find .mockReturnValueOnce( of( createBatch('Discover', 'Canvas'), @@ -70,35 +88,37 @@ describe('SearchBar', () => { ) .mockReturnValueOnce(of(createBatch('Discover', { id: 'My Dashboard', type: 'test' }))); - const component = mountWithIntl( + component = mountWithIntl( ); - expect(findSpy).toHaveBeenCalledTimes(0); - component.find('input[data-test-subj="header-search"]').simulate('focus'); - jest.runAllTimers(); - component.update(); - expect(findSpy).toHaveBeenCalledTimes(1); - expect(findSpy).toHaveBeenCalledWith('', {}); + expect(searchService.find).toHaveBeenCalledTimes(0); + + triggerFocus(); + update(); + + expect(searchService.find).toHaveBeenCalledTimes(1); + expect(searchService.find).toHaveBeenCalledWith('', {}); expect(getSelectableProps(component).options).toMatchSnapshot(); - await wait(() => getSearchProps(component).onKeyUpCapture({ currentTarget: { value: 'd' } })); - jest.runAllTimers(); - component.update(); + + await simulateTypeChar('d'); + update(); + expect(getSelectableProps(component).options).toMatchSnapshot(); - expect(findSpy).toHaveBeenCalledTimes(2); - expect(findSpy).toHaveBeenCalledWith('d', {}); + expect(searchService.find).toHaveBeenCalledTimes(2); + expect(searchService.find).toHaveBeenCalledWith('d', {}); }); it('supports keyboard shortcuts', () => { mountWithIntl( @@ -113,4 +133,44 @@ describe('SearchBar', () => { expect(document.activeElement).toMatchSnapshot(); }); + + it('only display results from the last search', async () => { + const firstSearchTrigger = new BehaviorSubject(false); + const firstSearch = firstSearchTrigger.pipe( + filter((event) => event), + map(() => { + return createBatch('Discover', 'Canvas'); + }) + ); + const secondSearch = of(createBatch('Visualize', 'Map')); + + searchService.find.mockReturnValueOnce(firstSearch).mockReturnValueOnce(secondSearch); + + component = mountWithIntl( + + ); + + triggerFocus(); + update(); + + expect(searchService.find).toHaveBeenCalledTimes(1); + + await simulateTypeChar('d'); + update(); + + expect(getDisplayedOptionsLabel().length).toBe(2); + expect(getDisplayedOptionsLabel()).toEqual(expect.arrayContaining(['Visualize', 'Map'])); + + firstSearchTrigger.next(true); + + update(); + + expect(getDisplayedOptionsLabel().length).toBe(2); + expect(getDisplayedOptionsLabel()).toEqual(expect.arrayContaining(['Visualize', 'Map'])); + }); }); diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index 4ca0f8cf81b7b..ea2271286883d 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -18,8 +18,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { Subscription } from 'rxjs'; import { ApplicationStart } from 'kibana/public'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useRef } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import useEvent from 'react-use/lib/useEvent'; import useMountedState from 'react-use/lib/useMountedState'; @@ -45,48 +46,73 @@ const clearField = (field: HTMLInputElement) => { const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' '); const blurEvent = new FocusEvent('blur'); +const sortByScore = (a: GlobalSearchResult, b: GlobalSearchResult): number => { + if (a.score < b.score) return 1; + if (a.score > b.score) return -1; + return 0; +}; + +const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => { + const titleA = a.title.toUpperCase(); // ignore upper and lowercase + const titleB = b.title.toUpperCase(); // ignore upper and lowercase + if (titleA < titleB) return -1; + if (titleA > titleB) return 1; + return 0; +}; + +const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewideOption => { + const { id, title, url, icon, type, meta } = result; + const option: EuiSelectableTemplateSitewideOption = { + key: id, + label: title, + url, + }; + + if (icon) { + option.icon = { type: icon }; + } + + if (type === 'application') { + option.meta = [{ text: meta?.categoryLabel as string }]; + } else { + option.meta = [{ text: cleanMeta(type) }]; + } + + return option; +}; + export function SearchBar({ globalSearch, navigateToUrl, basePathUrl, darkMode }: Props) { const isMounted = useMountedState(); const [searchValue, setSearchValue] = useState(''); const [searchRef, setSearchRef] = useState(null); + const searchSubscription = useRef(null); const [options, _setOptions] = useState([] as EuiSelectableTemplateSitewideOption[]); const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; const setOptions = useCallback( (_options: GlobalSearchResult[]) => { - if (!isMounted()) return; - - _setOptions([ - ..._options.map(({ id, title, url, icon, type, meta }) => { - const option: EuiSelectableTemplateSitewideOption = { - key: id, - label: title, - url, - }; - - if (icon) option.icon = { type: icon }; - - if (type === 'application') option.meta = [{ text: meta?.categoryLabel as string }]; - else option.meta = [{ text: cleanMeta(type) }]; + if (!isMounted()) { + return; + } - return option; - }), - ]); + _setOptions(_options.map(resultToOption)); }, [isMounted, _setOptions] ); useDebounce( () => { + // cancel pending search if not completed yet + if (searchSubscription.current) { + searchSubscription.current.unsubscribe(); + searchSubscription.current = null; + } + let arr: GlobalSearchResult[] = []; - globalSearch(searchValue, {}).subscribe({ + searchSubscription.current = globalSearch(searchValue, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { - arr = [...results, ...arr].sort((a, b) => { - if (a.score < b.score) return 1; - if (a.score > b.score) return -1; - return 0; - }); + arr = [...results, ...arr].sort(sortByScore); setOptions(arr); return; } @@ -94,13 +120,7 @@ export function SearchBar({ globalSearch, navigateToUrl, basePathUrl, darkMode } // if searchbar is empty, filter to only applications and sort alphabetically results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); - arr = [...results, ...arr].sort((a, b) => { - const titleA = a.title.toUpperCase(); // ignore upper and lowercase - const titleB = b.title.toUpperCase(); // ignore upper and lowercase - if (titleA < titleB) return -1; - if (titleA > titleB) return 1; - return 0; - }); + arr = [...results, ...arr].sort(sortByTitle); setOptions(arr); }, diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index 352191658ed0d..b556e2785b4b4 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -5,6 +5,7 @@ */ import { EMPTY } from 'rxjs'; +import { toArray } from 'rxjs/operators'; import { TestScheduler } from 'rxjs/testing'; import { SavedObjectsFindResponse, @@ -114,8 +115,8 @@ describe('savedObjectsResultProvider', () => { expect(provider.id).toBe('savedObjects'); }); - it('calls `savedObjectClient.find` with the correct parameters', () => { - provider.find('term', defaultOption, context); + it('calls `savedObjectClient.find` with the correct parameters', async () => { + await provider.find('term', defaultOption, context).toPromise(); expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ @@ -128,6 +129,13 @@ describe('savedObjectsResultProvider', () => { }); }); + it('does not call `savedObjectClient.find` if `term` is empty', async () => { + const results = await provider.find('', defaultOption, context).pipe(toArray()).toPromise(); + + expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); + expect(results).toEqual([[]]); + }); + it('converts the saved objects to results', async () => { context.core.savedObjects.client.find.mockResolvedValue( createFindResponse([ diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index 1c79380fe17fd..3861858a53626 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { from, combineLatest } from 'rxjs'; +import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; @@ -13,6 +13,10 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = return { id: 'savedObjects', find: (term, { aborted$, maxResults, preference }, { core }) => { + if (!term) { + return of([]); + } + const { capabilities, savedObjects: { client, typeRegistry }, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index 06f57896d4900..37df55f784252 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -127,7 +127,7 @@ describe('Index Templates tab', () => { const indexTemplate = templates[i]; const { name, indexPatterns, ilmPolicy, composedOf, template } = indexTemplate; - const hasContent = !!template.settings || !!template.mappings || !!template.aliases; + const hasContent = !!template?.settings || !!template?.mappings || !!template?.aliases; const ilmPolicyName = ilmPolicy && ilmPolicy.name ? ilmPolicy.name : ''; const composedOfString = composedOf ? composedOf.join(',') : ''; @@ -152,7 +152,7 @@ describe('Index Templates tab', () => { const legacyIndexTemplate = legacyTemplates[i]; const { name, indexPatterns, ilmPolicy, template } = legacyIndexTemplate; - const hasContent = !!template.settings || !!template.mappings || !!template.aliases; + const hasContent = !!template?.settings || !!template?.mappings || !!template?.aliases; const ilmPolicyName = ilmPolicy && ilmPolicy.name ? ilmPolicy.name : ''; try { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx index 8b74e9fb0cdf8..f0c0128fd6463 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx @@ -112,7 +112,7 @@ describe('', () => { name: `${templateToClone.name}-copy`, indexPatterns: DEFAULT_INDEX_PATTERNS, }; - // @ts-expect-error + delete expected.template; // As no settings, mappings or aliases have been defined, no "template" param is sent expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); diff --git a/x-pack/plugins/index_management/common/lib/template_serialization.ts b/x-pack/plugins/index_management/common/lib/template_serialization.ts index 1803d89a40016..bb7604f06c302 100644 --- a/x-pack/plugins/index_management/common/lib/template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/template_serialization.ts @@ -85,7 +85,7 @@ export function deserializeTemplateList( ): TemplateListItem[] { return indexTemplates.map(({ name, index_template: templateSerialized }) => { const { - template: { mappings, settings, aliases }, + template: { mappings, settings, aliases } = {}, ...deserializedTemplate } = deserializeTemplate({ name, ...templateSerialized }, cloudManagedTemplatePrefix); @@ -149,7 +149,7 @@ export function deserializeLegacyTemplateList( ): TemplateListItem[] { return Object.entries(indexTemplatesByName).map(([name, templateSerialized]) => { const { - template: { mappings, settings, aliases }, + template: { mappings, settings, aliases } = {}, ...deserializedTemplate } = deserializeLegacyTemplate({ name, ...templateSerialized }, cloudManagedTemplatePrefix); diff --git a/x-pack/plugins/index_management/common/types/templates.ts b/x-pack/plugins/index_management/common/types/templates.ts index eda00ec819159..d1b51fe5b89bf 100644 --- a/x-pack/plugins/index_management/common/types/templates.ts +++ b/x-pack/plugins/index_management/common/types/templates.ts @@ -13,7 +13,7 @@ import { Mappings } from './mappings'; */ export interface TemplateSerialized { index_patterns: string[]; - template: { + template?: { settings?: IndexSettings; aliases?: Aliases; mappings?: Mappings; @@ -33,7 +33,7 @@ export interface TemplateSerialized { export interface TemplateDeserialized { name: string; indexPatterns: string[]; - template: { + template?: { settings?: IndexSettings; aliases?: Aliases; mappings?: Mappings; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/other_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/other_datatype.test.tsx new file mode 100644 index 0000000000000..c1474b0ec6781 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/other_datatype.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; + +describe('Mappings editor: other datatype', () => { + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + let onChangeHandler: jest.Mock = jest.fn(); + let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + let testBed: MappingsEditorTestBed; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + onChangeHandler = jest.fn(); + getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + }); + + test('allow to add custom field type', async () => { + await act(async () => { + testBed = setup({ onChange: onChangeHandler }); + }); + testBed.component.update(); + + const { + component, + actions: { addField }, + } = testBed; + + await addField('myField', 'other', 'customType'); + + const mappings = { + properties: { + myField: { + type: 'customType', + }, + }, + }; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(mappings); + }); + + test('allow to change a field type to a custom type', async () => { + const defaultMappings = { + properties: { + myField: { + type: 'text', + }, + }, + }; + + let updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + testBed.component.update(); + + const { + component, + find, + form, + actions: { startEditField, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout to edit the field + await startEditField('myField'); + + // Change the field type + await act(async () => { + find('mappingsEditorFieldEdit.fieldType').simulate('change', [ + { + label: 'other', + value: 'other', + }, + ]); + }); + component.update(); + + form.setInputValue('mappingsEditorFieldEdit.fieldSubType', 'customType'); + + // Save the field and close the flyout + await updateFieldAndCloseFlyout(); + + updatedMappings = { + properties: { + myField: { + type: 'customType', + }, + }, + }; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(updatedMappings); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index e123dea6ff2ff..2eb56a97dc3a0 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -149,7 +149,7 @@ const createActions = (testBed: TestBed) => { return { field: find(testSubject as TestSubjects), testSubject }; }; - const addField = async (name: string, type: string) => { + const addField = async (name: string, type: string, subType?: string) => { await act(async () => { form.setInputValue('nameParameterInput', name); find('createFieldForm.fieldType').simulate('change', [ @@ -160,6 +160,17 @@ const createActions = (testBed: TestBed) => { ]); }); + component.update(); + + if (subType !== undefined) { + await act(async () => { + if (type === 'other') { + // subType is a text input + form.setInputValue('createFieldForm.fieldSubType', subType); + } + }); + } + await act(async () => { find('createFieldForm.addButton').simulate('click'); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 7ec78646a654e..2b56a0e68c46b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useRef } from 'react'; import { EuiSpacer } from '@elastic/eui'; -import { useForm, Form, SerializerFunc } from '../../shared_imports'; +import { useForm, Form } from '../../shared_imports'; import { GenericObject, MappingsConfiguration } from '../../types'; import { useDispatch } from '../../mappings_state_context'; import { DynamicMappingSection } from './dynamic_mapping_section'; @@ -19,7 +19,7 @@ interface Props { value?: MappingsConfiguration; } -const formSerializer: SerializerFunc = (formData) => { +const formSerializer = (formData: GenericObject) => { const { dynamicMapping: { enabled: dynamicMappingsEnabled, @@ -88,7 +88,7 @@ const formDeserializer = (formData: GenericObject) => { export const ConfigurationForm = React.memo(({ value }: Props) => { const isMounted = useRef(false); - const { form } = useForm({ + const { form } = useForm({ schema: configurationFormSchema, serializer: formSerializer, deserializer: formDeserializer, @@ -108,7 +108,7 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { validate, submitForm: submit, }, - }); + } as any); }); return subscription.unsubscribe; @@ -130,7 +130,7 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { // Save a snapshot of the form state so we can get back to it when navigating back to the tab const configurationData = getFormData(); - dispatch({ type: 'configuration.save', value: configurationData }); + dispatch({ type: 'configuration.save', value: configurationData as any }); }; }, [getFormData, dispatch]); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx index 8742dfc916924..2ab594d9d57fb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx @@ -11,7 +11,7 @@ import { EuiLink, EuiCode } from '@elastic/eui'; import { documentationService } from '../../../../services/documentation'; import { FormSchema, FIELD_TYPES, VALIDATION_TYPES, fieldValidators } from '../../shared_imports'; -import { ComboBoxOption, MappingsConfiguration } from '../../types'; +import { ComboBoxOption } from '../../types'; const { containsCharsField, isJsonField } = fieldValidators; @@ -28,7 +28,7 @@ const fieldPathComboBoxConfig = { deserializer: (values: string[]): ComboBoxOption[] => values.map((value) => ({ label: value })), }; -export const configurationFormSchema: FormSchema = { +export const configurationFormSchema: FormSchema = { metaField: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.configuration.metaFieldEditorLabel', { defaultMessage: '_meta field data', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_absolute.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_absolute.tsx index b446f9dae46bf..8a3bf5810459e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_absolute.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_absolute.tsx @@ -16,8 +16,8 @@ import { import { FieldHook } from '../../../shared_imports'; interface Props { - min: FieldHook; - max: FieldHook; + min: FieldHook; + max: FieldHook; } export const FielddataFrequencyFilterAbsolute = ({ min, max }: Props) => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_percentage.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_percentage.tsx index 97edba8179180..d1f810b1e8e13 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_percentage.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_percentage.tsx @@ -10,8 +10,8 @@ import { EuiDualRange, EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../../shared_imports'; interface Props { - min: FieldHook; - max: FieldHook; + min: FieldHook; + max: FieldHook; } export const FielddataFrequencyFilterPercentage = ({ min, max }: Props) => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_parameter.tsx index df49282b51e74..358f01833e9fe 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_parameter.tsx @@ -34,6 +34,11 @@ interface Props { type ValueType = 'percentage' | 'absolute'; +interface FieldsType { + min: number; + max: number; +} + export const FieldDataParameter = ({ field, defaultToggleValue }: Props) => { const [valueType, setValueType] = useState( field.source.fielddata_frequency_filter !== undefined @@ -43,21 +48,22 @@ export const FieldDataParameter = ({ field, defaultToggleValue }: Props) => { : 'percentage' ); - const getConfig = (fieldProp: 'min' | 'max', type = valueType) => - type === 'percentage' - ? getFieldConfig('fielddata_frequency_filter_percentage', fieldProp) - : getFieldConfig('fielddata_frequency_filter_absolute', fieldProp); + function getConfig(fieldProp: 'min' | 'max', type = valueType) { + return type === 'percentage' + ? getFieldConfig('fielddata_frequency_filter_percentage', fieldProp) + : getFieldConfig('fielddata_frequency_filter_absolute', fieldProp); + } - const switchType = (min: FieldHook, max: FieldHook) => () => { + const switchType = (min: FieldHook, max: FieldHook) => () => { const nextValueType = valueType === 'percentage' ? 'absolute' : 'percentage'; - const nextMinConfig = getConfig('min', nextValueType); - const nextMaxConfig = getConfig('max', nextValueType); + const nextMinConfig = getConfig('min', nextValueType); + const nextMaxConfig = getConfig('max', nextValueType); min.setValue( - nextMinConfig.deserializer?.(nextMinConfig.defaultValue) ?? nextMinConfig.defaultValue + nextMinConfig.deserializer?.(nextMinConfig.defaultValue!) ?? nextMinConfig.defaultValue! ); max.setValue( - nextMaxConfig.deserializer?.(nextMaxConfig.defaultValue) ?? nextMaxConfig.defaultValue + nextMaxConfig.deserializer?.(nextMaxConfig.defaultValue!) ?? nextMaxConfig.defaultValue! ); setValueType(nextValueType); @@ -85,15 +91,15 @@ export const FieldDataParameter = ({ field, defaultToggleValue }: Props) => { defaultToggleValue={defaultToggleValue} > {/* fielddata_frequency_filter */} - fields={{ min: { path: 'fielddata_frequency_filter.min', - config: getConfig('min'), + config: getConfig('min'), }, max: { path: 'fielddata_frequency_filter.max', - config: getConfig('max'), + config: getConfig('max'), }, }} > diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_json_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_json_parameter.tsx index 64e50f711a249..32a9926b2d925 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_json_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_json_parameter.tsx @@ -23,7 +23,7 @@ const { isJsonField } = fieldValidators; * We use it to store custom defined parameters in a field called "otherTypeJson". */ -const fieldConfig: FieldConfig = { +const fieldConfig: FieldConfig = { label: i18n.translate('xpack.idxMgmt.mappingsEditor.otherTypeJsonFieldLabel', { defaultMessage: 'Type Parameters JSON', }), diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_name_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_name_parameter.tsx index 6004e484323a1..8043a0deaf8da 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_name_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_name_parameter.tsx @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { UseField, TextField, FieldConfig } from '../../../shared_imports'; -import { fieldValidators } from '../../../shared_imports'; - -const { emptyField } = fieldValidators; +import { UseField, TextField, FieldConfig, FieldHook } from '../../../shared_imports'; +import { getFieldConfig } from '../../../lib'; /** * This is a special component that does not have an explicit entry in {@link PARAMETERS_DEFINITION}. @@ -18,25 +16,69 @@ const { emptyField } = fieldValidators; * We use it to store the name of types unknown to the mappings editor in the "subType" path. */ +type FieldType = [{ value: string }]; + +const typeParameterConfig = getFieldConfig('type'); + const fieldConfig: FieldConfig = { label: i18n.translate('xpack.idxMgmt.mappingsEditor.otherTypeNameFieldLabel', { defaultMessage: 'Type Name', }), defaultValue: '', + deserializer: typeParameterConfig.deserializer, + serializer: typeParameterConfig.serializer, validations: [ { - validator: emptyField( - i18n.translate( - 'xpack.idxMgmt.mappingsEditor.parameters.validations.otherTypeNameIsRequiredErrorMessage', - { - defaultMessage: 'The type name is required.', - } - ) - ), + validator: ({ value: fieldValue }) => { + if ((fieldValue as FieldType)[0].value.trim() === '') { + return { + message: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.parameters.validations.otherTypeNameIsRequiredErrorMessage', + { + defaultMessage: 'The type name is required.', + } + ), + }; + } + }, }, ], }; +interface Props { + field: FieldHook; +} + +/** + * The "subType" parameter can be configured either with a ComboBox (when the type is known) + * or with a TextField (when the type is unknown). This causes its value to have different type + * (either an array of object either a string). In order to align both value and let the consumer of + * the value worry about a single type, we will create a custom TextField component that works with the + * array of object that the ComboBox works with. + */ +const CustomTextField = ({ field }: Props) => { + const { setValue } = field; + + const transformedField: FieldHook = { + ...field, + value: field.value[0]?.value ?? '', + }; + + const onChange = useCallback( + (e: React.ChangeEvent) => { + setValue([{ value: e.target.value }]); + }, + [setValue] + ); + + return ( + + ); +}; + export const OtherTypeNameParameter = () => ( - + ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index 95575124b6abd..faa0c8c9dc792 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -91,8 +91,8 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit, updateF {({ type, subType }) => { const linkDocumentation = - documentationService.getTypeDocLink(subType?.[0].value) || - documentationService.getTypeDocLink(type?.[0].value); + documentationService.getTypeDocLink(subType?.[0]?.value) || + documentationService.getTypeDocLink(type?.[0]?.value); if (!linkDocumentation) { return null; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx index c0e68b082c310..ce349b2c6104f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx @@ -93,7 +93,7 @@ export const EditFieldFormRow = React.memo( showLabel={false} /> ) : ( - path={formFieldPath} config={{ ...getFieldConfig(configPath ? configPath : formFieldPath), diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts index daca85f95b0b9..08ccd27d5bca3 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts @@ -7,11 +7,10 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../shared_imports'; -import { MappingsTemplates } from '../../types'; const { isJsonField } = fieldValidators; -export const templatesFormSchema: FormSchema = { +export const templatesFormSchema: FormSchema<{ dynamicTemplates: any[] }> = { dynamicTemplates: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.templates.dynamicTemplatesEditorLabel', { defaultMessage: 'Dynamic templates data', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx index 4c9786d88bfa2..1434b7d4b4429 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx @@ -168,7 +168,7 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio }, ]; } - return []; + return [{ value: '' }]; }, serializer: (fieldType: ComboBoxOption[] | undefined) => fieldType && fieldType.length ? fieldType[0].value : fieldType, @@ -273,15 +273,15 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio min: { fieldConfig: { defaultValue: 0.01, - serializer: (value) => (value === '' ? '' : toInt(value) / 100), - deserializer: (value) => Math.round(value * 100), + serializer: (value: string) => (value === '' ? '' : toInt(value) / 100), + deserializer: (value: number) => Math.round(value * 100), } as FieldConfig, }, max: { fieldConfig: { defaultValue: 1, - serializer: (value) => (value === '' ? '' : toInt(value) / 100), - deserializer: (value) => Math.round(value * 100), + serializer: (value: string) => (value === '' ? '' : toInt(value) / 100), + deserializer: (value: number) => Math.round(value * 100), } as FieldConfig, }, }, @@ -949,8 +949,8 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio ), }, ], - serializer: (value: AliasOption[]) => (value.length === 0 ? '' : value[0].id), - } as FieldConfig, + serializer: (value) => (value.length === 0 ? '' : value[0].id), + } as FieldConfig, targetTypesNotAllowed: ['object', 'nested', 'alias'] as DataType[], schema: t.string, }, @@ -991,14 +991,14 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio fieldConfig: { type: FIELD_TYPES.NUMBER, defaultValue: 2, - serializer: (value) => (value === '' ? '' : toInt(value)), + serializer: (value: string) => (value === '' ? '' : toInt(value)), } as FieldConfig, }, max_chars: { fieldConfig: { type: FIELD_TYPES.NUMBER, defaultValue: 5, - serializer: (value) => (value === '' ? '' : toInt(value)), + serializer: (value: string) => (value === '' ? '' : toInt(value)), } as FieldConfig, }, }, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index d96f20683216a..fd7aa41638505 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -92,7 +92,7 @@ export const getTypeLabelFromField = (field: Field) => { export const getFieldConfig = ( param: ParameterName, prop?: string -): FieldConfig => { +): FieldConfig => { if (prop !== undefined) { if ( !(PARAMETERS_DEFINITION[param] as any).props || diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index 926b4c9d12bee..ee4dd55a5801f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -25,7 +25,7 @@ export interface DataTypeDefinition { export interface ParameterDefinition { title?: string; description?: JSX.Element | string; - fieldConfig: FieldConfig; + fieldConfig: FieldConfig; schema?: any; props?: { [key: string]: ParameterDefinition }; documentation?: { diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 3a03835e85970..8e84abb5ce495 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -117,7 +117,7 @@ export const TemplateForm = ({ }; const { - template: { settings, mappings, aliases }, + template: { settings, mappings, aliases } = {}, composedOf, _kbnMeta, ...logistics @@ -170,18 +170,19 @@ export const TemplateForm = ({ const cleanupTemplateObject = (template: TemplateDeserialized) => { const outputTemplate = { ...template }; - if (outputTemplate.template.settings === undefined) { - delete outputTemplate.template.settings; - } - if (outputTemplate.template.mappings === undefined) { - delete outputTemplate.template.mappings; - } - if (outputTemplate.template.aliases === undefined) { - delete outputTemplate.template.aliases; - } - if (Object.keys(outputTemplate.template).length === 0) { - // @ts-expect-error - delete outputTemplate.template; + if (outputTemplate.template) { + if (outputTemplate.template.settings === undefined) { + delete outputTemplate.template.settings; + } + if (outputTemplate.template.mappings === undefined) { + delete outputTemplate.template.mappings; + } + if (outputTemplate.template.aliases === undefined) { + delete outputTemplate.template.aliases; + } + if (Object.keys(outputTemplate.template).length === 0) { + delete outputTemplate.template; + } } return outputTemplate; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx index 94891297c857e..48083f324de3d 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx @@ -161,9 +161,7 @@ export const TemplateDetailsContent = ({ } if (templateDetails) { - const { - template: { settings, mappings, aliases }, - } = templateDetails; + const { template: { settings, mappings, aliases } = {} } = templateDetails; const tabToComponentMap: Record = { [SUMMARY_TAB_ID]: , diff --git a/x-pack/plugins/ingest_manager/README.md b/x-pack/plugins/ingest_manager/README.md index a95a2582f1f60..65df682c23659 100644 --- a/x-pack/plugins/ingest_manager/README.md +++ b/x-pack/plugins/ingest_manager/README.md @@ -8,7 +8,8 @@ - [code for adding the routes](https://github.com/elastic/kibana/blob/1f27d349533b1c2865c10c45b2cf705d7416fb36/x-pack/plugins/ingest_manager/server/plugin.ts#L115-L133) - [Integration tests](server/integration_tests/router.test.ts) - Both EPM and Fleet require `ingestManager` be enabled. They are not standalone features. -- For Gold+ license, a custom package registry URL can be used by setting `xpack.ingestManager.registryUrl=http://localhost:8080` +- For Enterprise license, a custom package registry URL can be used by setting `xpack.ingestManager.registryUrl=http://localhost:8080` + - This property is currently only for internal Elastic development and is unsupported ## Fleet Requirements diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 69672dfb9ec6c..2b1d24f14874f 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -91,6 +91,7 @@ export const AGENT_API_ROUTES = { BULK_REASSIGN_PATTERN: `${FLEET_API_ROOT}/agents/bulk_reassign`, STATUS_PATTERN: `${FLEET_API_ROOT}/agent-status`, UPGRADE_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/upgrade`, + BULK_UPGRADE_PATTERN: `${FLEET_API_ROOT}/agents/bulk_upgrade`, }; export const ENROLLMENT_API_KEY_ROUTES = { diff --git a/x-pack/plugins/ingest_manager/common/services/agent_status.ts b/x-pack/plugins/ingest_manager/common/services/agent_status.ts index 70f4d7f9344f9..cd990d70c3612 100644 --- a/x-pack/plugins/ingest_manager/common/services/agent_status.ts +++ b/x-pack/plugins/ingest_manager/common/services/agent_status.ts @@ -19,9 +19,6 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (!agent.last_checkin) { return 'enrolling'; } - if (agent.upgrade_started_at && !agent.upgraded_at) { - return 'upgrading'; - } const msLastCheckIn = new Date(lastCheckIn || 0).getTime(); const msSinceLastCheckIn = new Date().getTime() - msLastCheckIn; @@ -33,6 +30,9 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (agent.last_checkin_status === 'degraded') { return 'degraded'; } + if (agent.upgrade_started_at && !agent.upgraded_at) { + return 'updating'; + } if (intervalsSinceLastCheckIn >= 4) { return 'offline'; } @@ -61,3 +61,7 @@ export function buildKueryForOfflineAgents() { (4 * AGENT_POLLING_THRESHOLD_MS) / 1000 }s AND not (${buildKueryForErrorAgents()})`; } + +export function buildKueryForUpdatingAgents() { + return `${AGENT_SAVED_OBJECT_TYPE}.upgrade_started_at:*`; +} diff --git a/x-pack/plugins/ingest_manager/common/services/index.ts b/x-pack/plugins/ingest_manager/common/services/index.ts index 4bffa01ad5ee2..19285e921e931 100644 --- a/x-pack/plugins/ingest_manager/common/services/index.ts +++ b/x-pack/plugins/ingest_manager/common/services/index.ts @@ -13,3 +13,4 @@ export { decodeCloudId } from './decode_cloud_id'; export { isValidNamespace } from './is_valid_namespace'; export { isDiffPathProtocol } from './is_diff_path_protocol'; export { LicenseService } from './license'; +export { isAgentUpgradeable } from './is_agent_upgradeable'; diff --git a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts new file mode 100644 index 0000000000000..cb087a3b8f805 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isAgentUpgradeable } from './is_agent_upgradeable'; +import { Agent } from '../types/models/agent'; + +const getAgent = (version: string, upgradeable: boolean): Agent => { + const agent: Agent = { + id: 'de9006e1-54a7-4320-b24e-927e6fe518a8', + active: true, + policy_id: '63a284b0-0334-11eb-a4e0-09883c57114b', + type: 'PERMANENT', + enrolled_at: '2020-09-30T20:24:08.347Z', + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + id: 'de9006e1-54a7-4320-b24e-927e6fe518a8', + version, + snapshot: false, + 'build.original': + '8.0.0 (build: e2ef4fc375a5ece83d5d38f57b2977d7866b5819 at 2020-09-30 20:21:35 +0000 UTC)', + }, + }, + host: { + architecture: 'x86_64', + hostname: 'Sandras-MBP.fios-router.home', + name: 'Sandras-MBP.fios-router.home', + id: '1112D0AD-526D-5268-8E86-765D35A0F484', + ip: [ + '127.0.0.1/8', + '::1/128', + 'fe80::1/64', + 'fe80::aede:48ff:fe00:1122/64', + 'fe80::4fc:2526:7d51:19cc/64', + '192.168.1.161/24', + 'fe80::3083:5ff:fe30:4b00/64', + 'fe80::3083:5ff:fe30:4b00/64', + 'fe80::f7fb:518e:2c3c:7815/64', + 'fe80::2abd:20e3:9bc3:c054/64', + 'fe80::531a:20ab:1f38:7f9/64', + ], + mac: [ + 'a6:83:e7:b0:1a:d2', + 'ac:de:48:00:11:22', + 'a4:83:e7:b0:1a:d2', + '82:c5:c2:25:b0:01', + '82:c5:c2:25:b0:00', + '82:c5:c2:25:b0:05', + '82:c5:c2:25:b0:04', + '82:c5:c2:25:b0:01', + '06:83:e7:b0:1a:d2', + '32:83:05:30:4b:00', + '32:83:05:30:4b:00', + ], + }, + os: { + family: 'darwin', + kernel: '19.4.0', + platform: 'darwin', + version: '10.15.4', + name: 'Mac OS X', + full: 'Mac OS X(10.15.4)', + }, + }, + access_api_key_id: 'A_6v4HQBEEDXi-A9vxPE', + default_api_key_id: 'BP6v4HQBEEDXi-A95xMk', + policy_revision: 1, + packages: ['system'], + last_checkin: '2020-10-01T14:43:27.255Z', + current_error_events: [], + status: 'online', + }; + if (upgradeable) { + agent.local_metadata.elastic.agent.upgradeable = true; + } + return agent; +}; +describe('Ingest Manager - isAgentUpgradeable', () => { + it('returns false if agent reports not upgradeable with agent version < kibana version', () => { + expect(isAgentUpgradeable(getAgent('7.9.0', false), '8.0.0')).toBe(false); + }); + it('returns false if agent reports not upgradeable with agent version > kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', false), '7.9.0')).toBe(false); + }); + it('returns false if agent reports not upgradeable with agent version === kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', false), '8.0.0')).toBe(false); + }); + it('returns false if agent reports upgradeable, with agent version === kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', true), '8.0.0')).toBe(false); + }); + it('returns false if agent reports upgradeable, with agent version > kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', true), '7.9.0')).toBe(false); + }); + it('returns true if agent reports upgradeable, with agent version < kibana version', () => { + expect(isAgentUpgradeable(getAgent('7.9.0', true), '8.0.0')).toBe(true); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts new file mode 100644 index 0000000000000..5f96e108e6184 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import semver from 'semver'; +import { Agent } from '../types'; + +export function isAgentUpgradeable(agent: Agent, kibanaVersion: string) { + let agentVersion: string; + if (typeof agent?.local_metadata?.elastic?.agent?.version === 'string') { + agentVersion = agent.local_metadata.elastic.agent.version; + } else { + return false; + } + const kibanaVersionParsed = semver.parse(kibanaVersion); + const agentVersionParsed = semver.parse(agentVersion); + if (!agentVersionParsed || !kibanaVersionParsed) return false; + if (!agent.local_metadata.elastic.agent.upgradeable) return false; + return semver.lt(agentVersionParsed, kibanaVersionParsed); +} diff --git a/x-pack/plugins/ingest_manager/common/services/license.ts b/x-pack/plugins/ingest_manager/common/services/license.ts index 6d9b20a8456c0..381db149f7d6d 100644 --- a/x-pack/plugins/ingest_manager/common/services/license.ts +++ b/x-pack/plugins/ingest_manager/common/services/license.ts @@ -43,4 +43,11 @@ export class LicenseService { this.licenseInformation?.hasAtLeast('gold') ); } + public isEnterprise() { + return ( + this.licenseInformation?.isAvailable && + this.licenseInformation?.isActive && + this.licenseInformation?.hasAtLeast('enterprise') + ); + } } diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index 3c3534926908a..c709794f2ce55 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -135,6 +135,9 @@ export const agentRouteService = { getReassignPath: (agentId: string) => AGENT_API_ROUTES.REASSIGN_PATTERN.replace('{agentId}', agentId), getBulkReassignPath: () => AGENT_API_ROUTES.BULK_REASSIGN_PATTERN, + getUpgradePath: (agentId: string) => + AGENT_API_ROUTES.UPGRADE_PATTERN.replace('{agentId}', agentId), + getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, }; diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index 6ac783820ce82..215764939d3d1 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -19,7 +19,7 @@ export type AgentStatus = | 'warning' | 'enrolling' | 'unenrolling' - | 'upgrading' + | 'updating' | 'degraded'; export type AgentActionType = 'POLICY_CHANGE' | 'UNENROLL' | 'UPGRADE'; @@ -89,6 +89,7 @@ export interface NewAgentEvent { | 'STOPPING' | 'STOPPED' | 'DEGRADED' + | 'UPDATING' // Action results | 'DATA_DUMP' // Actions @@ -109,10 +110,8 @@ export interface AgentEvent extends NewAgentEvent { export type AgentEventSOAttributes = NewAgentEvent; -type MetadataValue = string | AgentMetadata; - export interface AgentMetadata { - [x: string]: MetadataValue; + [x: string]: any; } interface AgentBase { type: AgentType; @@ -129,7 +128,7 @@ interface AgentBase { policy_id?: string; policy_revision?: number | null; last_checkin?: string; - last_checkin_status?: 'error' | 'online' | 'degraded'; + last_checkin_status?: 'error' | 'online' | 'degraded' | 'updating'; user_provided_metadata: AgentMetadata; local_metadata: AgentMetadata; } diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index d2d1f22dda3a0..ea7fd60d1fa3f 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -20,6 +20,7 @@ export enum InstallStatus { } export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'install'; +export type InstallSource = 'registry' | 'upload'; export type EpmPackageInstallStatus = 'installed' | 'installing'; @@ -49,10 +50,8 @@ export enum AgentAssetType { export type RegistryRelease = 'ga' | 'beta' | 'experimental'; -// from /package/{name} -// type Package struct at https://github.com/elastic/package-registry/blob/master/util/package.go -// https://github.com/elastic/package-registry/blob/master/docs/api/package.json -export interface RegistryPackage { +// Fields common to packages that come from direct upload and the registry +export interface InstallablePackage { name: string; title?: string; version: string; @@ -61,7 +60,6 @@ export interface RegistryPackage { description: string; type: string; categories: string[]; - requirement: RequirementsByServiceName; screenshots?: RegistryImage[]; icons?: RegistryImage[]; assets?: string[]; @@ -69,6 +67,17 @@ export interface RegistryPackage { format_version: string; data_streams?: RegistryDataStream[]; policy_templates?: RegistryPolicyTemplate[]; +} + +// Uploaded package archives don't have extra fields +// Linter complaint disabled because this extra type is meant for better code readability +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ArchivePackage extends InstallablePackage {} + +// Registry packages do have extra fields. +// cf. type Package struct at https://github.com/elastic/package-registry/blob/master/util/package.go +export interface RegistryPackage extends InstallablePackage { + requirement: RequirementsByServiceName; download: string; path: string; } @@ -240,6 +249,7 @@ export interface Installation extends SavedObjectAttributes { install_status: EpmPackageInstallStatus; install_version: string; install_started_at: string; + install_source: InstallSource; } export type Installable = Installed | NotInstalled; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index ab4c372c4e1d6..da7d126c4ecd3 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -20,6 +20,7 @@ export interface GetAgentsRequest { perPage: number; kuery?: string; showInactive: boolean; + showUpgradeable?: boolean; }; } @@ -113,22 +114,38 @@ export interface PostAgentUnenrollRequest { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PostAgentUnenrollResponse {} +export interface PostBulkAgentUnenrollRequest { + body: { + agents: string[] | string; + force?: boolean; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PostBulkAgentUnenrollResponse {} + export interface PostAgentUpgradeRequest { params: { agentId: string; }; + body: { + source_uri?: string; + version: string; + }; } -export interface PostBulkAgentUnenrollRequest { + +export interface PostBulkAgentUpgradeRequest { body: { agents: string[] | string; - force?: boolean; + source_uri?: string; + version: string; }; } +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PostBulkAgentUpgradeResponse {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PostAgentUpgradeResponse {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PostBulkAgentUnenrollResponse {} export interface PutAgentReassignRequest { params: { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts index 64434e163f043..29843f6a3e5b1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts @@ -7,6 +7,7 @@ export { useCapabilities } from './use_capabilities'; export { useCore } from './use_core'; export { useConfig, ConfigContext } from './use_config'; +export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version'; export { useSetupDeps, useStartDeps, DepsContext } from './use_deps'; export { licenseService, useLicense } from './use_license'; export { useBreadcrumbs } from './use_breadcrumbs'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts new file mode 100644 index 0000000000000..a5113ca10d439 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +export const KibanaVersionContext = React.createContext(null); + +export function useKibanaVersion() { + const version = useContext(KibanaVersionContext); + if (version === null) { + throw new Error('KibanaVersionContext is not initialized'); + } + return version; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts index 41967fd068e0b..564e7b225cf45 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts @@ -22,6 +22,10 @@ import { GetAgentsResponse, GetAgentStatusRequest, GetAgentStatusResponse, + PostAgentUpgradeRequest, + PostBulkAgentUpgradeRequest, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, } from '../../types'; type RequestOptions = Pick, 'pollIntervalMs'>; @@ -126,3 +130,28 @@ export function sendPostBulkAgentUnenroll( ...options, }); } + +export function sendPostAgentUpgrade( + agentId: string, + body: PostAgentUpgradeRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getUpgradePath(agentId), + method: 'post', + body, + ...options, + }); +} + +export function sendPostBulkAgentUpgrade( + body: PostBulkAgentUpgradeRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getBulkUpgradePath(), + method: 'post', + body, + ...options, + }); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 0bef3c20ddd1a..c61a290cf2470 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -30,6 +30,7 @@ import { sendSetup, sendGetPermissionsCheck, licenseService, + KibanaVersionContext, } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; import { FleetStatusProvider } from './hooks/use_fleet_status'; @@ -235,6 +236,7 @@ const IngestManagerApp = ({ startDeps, config, history, + kibanaVersion, }: { basepath: string; coreStart: CoreStart; @@ -242,6 +244,7 @@ const IngestManagerApp = ({ startDeps: IngestManagerStartDeps; config: IngestManagerConfigType; history: AppMountParameters['history']; + kibanaVersion: string; }) => { const isDarkMode = useObservable(coreStart.uiSettings.get$('theme:darkMode')); return ( @@ -249,9 +252,11 @@ const IngestManagerApp = ({ - - - + + + + + @@ -264,7 +269,8 @@ export function renderApp( { element, appBasePath, history }: AppMountParameters, setupDeps: IngestManagerSetupDeps, startDeps: IngestManagerStartDeps, - config: IngestManagerConfigType + config: IngestManagerConfigType, + kibanaVersion: string ) { ReactDOM.render( , element ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx index ea5dcce8c05bb..9ed464401fdc6 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx @@ -7,10 +7,15 @@ import React, { memo, useState, useMemo } from 'react'; import { EuiPortal, EuiContextMenuItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; -import { useCapabilities } from '../../../../hooks'; +import { useCapabilities, useKibanaVersion } from '../../../../hooks'; import { ContextMenuActions } from '../../../../components'; -import { AgentUnenrollAgentModal, AgentReassignAgentPolicyFlyout } from '../../components'; +import { + AgentUnenrollAgentModal, + AgentReassignAgentPolicyFlyout, + AgentUpgradeAgentModal, +} from '../../components'; import { useAgentRefresh } from '../hooks'; +import { isAgentUpgradeable } from '../../../../services'; export const AgentDetailsActionMenu: React.FunctionComponent<{ agent: Agent; @@ -18,9 +23,11 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ onCancelReassign?: () => void; }> = memo(({ agent, assignFlyoutOpenByDefault = false, onCancelReassign }) => { const hasWriteCapabilites = useCapabilities().write; + const kibanaVersion = useKibanaVersion(); const refreshAgent = useAgentRefresh(); const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(assignFlyoutOpenByDefault); const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); const isUnenrolling = agent.status === 'unenrolling'; const onClose = useMemo(() => { @@ -51,6 +58,19 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ /> )} + {isUpgradeModalOpen && ( + + { + setIsUpgradeModalOpen(false); + refreshAgent(); + }} + /> + + )} )} , + { + setIsUpgradeModalOpen(true); + }} + > + + , ]} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx index 68abb43abac18..2493fda3317d2 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx @@ -13,15 +13,20 @@ import { EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { Agent, AgentPolicy } from '../../../../types'; -import { useLink } from '../../../../hooks'; +import { useKibanaVersion, useLink } from '../../../../hooks'; import { AgentHealth } from '../../components'; +import { isAgentUpgradeable } from '../../../../services'; export const AgentDetailsContent: React.FunctionComponent<{ agent: Agent; agentPolicy?: AgentPolicy; }> = memo(({ agent, agentPolicy }) => { const { getHref } = useLink(); + const kibanaVersion = useKibanaVersion(); return ( {[ @@ -69,8 +74,39 @@ export const AgentDetailsContent: React.FunctionComponent<{ description: typeof agent.local_metadata.elastic === 'object' && typeof agent.local_metadata.elastic.agent === 'object' && - typeof agent.local_metadata.elastic.agent.version === 'string' - ? agent.local_metadata.elastic.agent.version + typeof agent.local_metadata.elastic.agent.version === 'string' ? ( + + + {agent.local_metadata.elastic.agent.version} + + {isAgentUpgradeable(agent, kibanaVersion) ? ( + + + +   + + + + ) : null} + + ) : ( + '-' + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.releaseLabel', { + defaultMessage: 'Agent release', + }), + description: + typeof agent.local_metadata.elastic === 'object' && + typeof agent.local_metadata.elastic.agent === 'object' && + typeof agent.local_metadata.elastic.agent.snapshot === 'boolean' + ? agent.local_metadata.elastic.agent.snapshot === true + ? 'snapshot' + : 'stable' : '-', }, { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx index 56af9519bc1da..f597b9c72ab02 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx @@ -119,6 +119,14 @@ export const SUBTYPE_LABEL: { [key in AgentEvent['subtype']]: JSX.Element } = { /> ), + UPDATING: ( + + + + ), UNKNOWN: ( { params: { agentId, tabId = '' }, } = useRouteMatch<{ agentId: string; tabId?: string }>(); const { getHref } = useLink(); + const kibanaVersion = useKibanaVersion(); const { isLoading, isInitialRequest, @@ -144,6 +148,45 @@ export const AgentDetailsPage: React.FunctionComponent = () => { ), }, { isDivider: true }, + { + label: i18n.translate('xpack.ingestManager.agentDetails.agentVersionLabel', { + defaultMessage: 'Agent version', + }), + content: + typeof agentData.item.local_metadata.elastic === 'object' && + typeof agentData.item.local_metadata.elastic.agent === 'object' && + typeof agentData.item.local_metadata.elastic.agent.version === 'string' ? ( + + + {agentData.item.local_metadata.elastic.agent.version} + + {isAgentUpgradeable(agentData.item, kibanaVersion) ? ( + + + + ) : null} + + ) : ( + '-' + ), + }, + { isDivider: true }, { content: ( { + const kibanaVersion = useKibanaVersion(); // Bulk actions menu states const [isMenuOpen, setIsMenuOpen] = useState(false); const closeMenu = () => setIsMenuOpen(false); @@ -67,6 +73,7 @@ export const AgentBulkActions: React.FunctionComponent<{ // Actions states const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false); const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); // Check if user is working with only inactive agents const atLeastOneActiveAgentSelected = @@ -106,6 +113,20 @@ export const AgentBulkActions: React.FunctionComponent<{ setIsUnenrollModalOpen(true); }, }, + { + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected, + onClick: () => { + closeMenu(); + setIsUpgradeModalOpen(true); + }, + }, { name: ( )} + {isUpgradeModalOpen && ( + + { + setIsUpgradeModalOpen(false); + refreshAgents(); + }} + /> + + )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 0bc463ce98590..83cbb9ccb728c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -35,14 +35,16 @@ import { useLink, useBreadcrumbs, useLicense, + useKibanaVersion, } from '../../../hooks'; import { SearchBar, ContextMenuActions } from '../../../components'; -import { AgentStatusKueryHelper } from '../../../services'; +import { AgentStatusKueryHelper, isAgentUpgradeable } from '../../../services'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { AgentReassignAgentPolicyFlyout, AgentHealth, AgentUnenrollAgentModal, + AgentUpgradeAgentModal, } from '../components'; import { AgentBulkActions, SelectionMode } from './components/bulk_actions'; @@ -68,6 +70,12 @@ const statusFilters = [ defaultMessage: 'Error', }), }, + { + status: 'updating', + label: i18n.translate('xpack.ingestManager.agentList.statusUpdatingFilterText', { + defaultMessage: 'Updating', + }), + }, ] as Array<{ label: string; status: string }>; const RowActions = React.memo<{ @@ -75,11 +83,13 @@ const RowActions = React.memo<{ refresh: () => void; onReassignClick: () => void; onUnenrollClick: () => void; -}>(({ agent, refresh, onReassignClick, onUnenrollClick }) => { + onUpgradeClick: () => void; +}>(({ agent, refresh, onReassignClick, onUnenrollClick, onUpgradeClick }) => { const { getHref } = useLink(); const hasWriteCapabilites = useCapabilities().write; const isUnenrolling = agent.status === 'unenrolling'; + const kibanaVersion = useKibanaVersion(); const [isMenuOpen, setIsMenuOpen] = useState(false); return ( )} , + { + onUpgradeClick(); + }} + > + + , ]} /> ); @@ -146,10 +168,11 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || ''; const hasWriteCapabilites = useCapabilities().write; const isGoldPlus = useLicense().isGoldPlus(); + const kibanaVersion = useKibanaVersion(); // Agent data states const [showInactive, setShowInactive] = useState(false); - + const [showUpgradeable, setShowUpgradeable] = useState(false); // Table and search states const [search, setSearch] = useState(defaultKuery); const [selectionMode, setSelectionMode] = useState('manual'); @@ -189,6 +212,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Agent actions states const [agentToReassign, setAgentToReassign] = useState(undefined); const [agentToUnenroll, setAgentToUnenroll] = useState(undefined); + const [agentToUpgrade, setAgentToUpgrade] = useState(undefined); let kuery = search.trim(); if (selectedAgentPolicies.length) { @@ -199,7 +223,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { .map((agentPolicy) => `"${agentPolicy}"`) .join(' or ')})`; } - if (selectedStatus.length) { const kueryStatus = selectedStatus .map((status) => { @@ -208,6 +231,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return AgentStatusKueryHelper.buildKueryForOnlineAgents(); case 'offline': return AgentStatusKueryHelper.buildKueryForOfflineAgents(); + case 'updating': + return AgentStatusKueryHelper.buildKueryForUpdatingAgents(); case 'error': return AgentStatusKueryHelper.buildKueryForErrorAgents(); } @@ -229,6 +254,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { perPage: pagination.pageSize, kuery: kuery && kuery !== '' ? kuery : undefined, showInactive, + showUpgradeable, }, { pollIntervalMs: REFRESH_INTERVAL_MS, @@ -329,11 +355,29 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'local_metadata.elastic.agent.version', - width: '100px', + width: '200px', name: i18n.translate('xpack.ingestManager.agentList.versionTitle', { defaultMessage: 'Version', }), - render: (version: string, agent: Agent) => safeMetadata(version), + render: (version: string, agent: Agent) => ( + + + {safeMetadata(version)} + + {isAgentUpgradeable(agent, kibanaVersion) ? ( + + + +   + + + + ) : null} + + ), }, { field: 'last_checkin', @@ -356,6 +400,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { refresh={() => agentsRequest.resendRequest()} onReassignClick={() => setAgentToReassign(agent)} onUnenrollClick={() => setAgentToUnenroll(agent)} + onUpgradeClick={() => setAgentToUpgrade(agent)} /> ); }, @@ -421,6 +466,20 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} + {agentToUpgrade && ( + + { + setAgentToUpgrade(undefined); + agentsRequest.resendRequest(); + }} + version={kibanaVersion} + /> + + )} + {/* Search and filter bar */} @@ -519,13 +578,24 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { ))} + { + setShowUpgradeable(!showUpgradeable); + }} + > + + setShowInactive(!showInactive)} > diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx index 7c6c95cab420f..a16d4e7347ad1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx @@ -77,6 +77,14 @@ const Status = { /> ), + Upgrading: ( + + + + ), }; function getStatusComponent(agent: Agent): React.ReactElement { @@ -95,6 +103,8 @@ function getStatusComponent(agent: Agent): React.ReactElement { return Status.Unenrolling; case 'enrolling': return Status.Enrolling; + case 'updating': + return Status.Upgrading; default: return Status.Online; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx new file mode 100644 index 0000000000000..a59f503d2994b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Agent } from '../../../../types'; +import { sendPostAgentUpgrade, sendPostBulkAgentUpgrade, useCore } from '../../../../hooks'; + +interface Props { + onClose: () => void; + agents: Agent[] | string; + agentCount: number; + version: string; +} + +export const AgentUpgradeAgentModal: React.FunctionComponent = ({ + onClose, + agents, + agentCount, + version, +}) => { + const { notifications } = useCore(); + const [isSubmitting, setIsSubmitting] = useState(false); + const isSingleAgent = Array.isArray(agents) && agents.length === 1; + async function onSubmit() { + try { + setIsSubmitting(true); + const { error } = isSingleAgent + ? await sendPostAgentUpgrade((agents[0] as Agent).id, { + version, + }) + : await sendPostBulkAgentUpgrade({ + agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, + version, + }); + if (error) { + throw error; + } + setIsSubmitting(false); + const successMessage = isSingleAgent + ? i18n.translate('xpack.ingestManager.upgradeAgents.successSingleNotificationTitle', { + defaultMessage: 'Upgrading agent', + }) + : i18n.translate('xpack.ingestManager.upgradeAgents.successMultiNotificationTitle', { + defaultMessage: 'Upgrading agents', + }); + notifications.toasts.addSuccess(successMessage); + onClose(); + } catch (error) { + setIsSubmitting(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.ingestManager.upgradeAgents.fatalErrorNotificationTitle', { + defaultMessage: 'Error upgrading {count, plural, one {agent} other {agents}}', + values: { count: agentCount }, + }), + }); + } + } + + return ( + + + ) : ( + + ) + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={isSubmitting} + confirmButtonText={ + isSingleAgent ? ( + + ) : ( + + ) + } + > +

+ {isSingleAgent ? ( + + ) : ( + + )} +

+
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx index eea4ed3b712b1..3dd04b4f5b0b7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx @@ -9,3 +9,4 @@ export * from './agent_reassign_policy_flyout'; export * from './agent_enrollment_flyout'; export * from './agent_health'; export * from './agent_unenroll_modal'; +export * from './agent_upgrade_modal'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts index ed6ba5c891a0b..ee976d40402cc 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts @@ -26,4 +26,5 @@ export { doesAgentPolicyAlreadyIncludePackage, isValidNamespace, LicenseService, + isAgentUpgradeable, } from '../../../../common'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index e825448f359d6..386ffa5649cc2 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -53,6 +53,10 @@ export { PostAgentUnenrollResponse, PostBulkAgentUnenrollRequest, PostBulkAgentUnenrollResponse, + PostAgentUpgradeRequest, + PostBulkAgentUpgradeRequest, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, GetOneAgentEventsRequest, GetOneAgentEventsResponse, GetAgentStatusRequest, diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts index 59741ce79dd8b..cb1d59b698f0a 100644 --- a/x-pack/plugins/ingest_manager/public/plugin.ts +++ b/x-pack/plugins/ingest_manager/public/plugin.ts @@ -60,13 +60,16 @@ export class IngestManagerPlugin implements Plugin { private config: IngestManagerConfigType; + private kibanaVersion: string; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); + this.kibanaVersion = initializerContext.env.packageInfo.version; } public setup(core: CoreSetup, deps: IngestManagerSetupDeps) { const config = this.config; + const kibanaVersion = this.kibanaVersion; // Set up http client setHttpClient(core.http); @@ -88,7 +91,7 @@ export class IngestManagerPlugin IngestManagerStart ]; const { renderApp, teardownIngestManager } = await import('./applications/ingest_manager'); - const unmount = renderApp(coreStart, params, deps, startDeps, config); + const unmount = renderApp(coreStart, params, deps, startDeps, config, kibanaVersion); return () => { unmount(); diff --git a/x-pack/plugins/ingest_manager/server/errors/handlers.test.ts b/x-pack/plugins/ingest_manager/server/errors/handlers.test.ts index 361386a86d547..272d95c0b3688 100644 --- a/x-pack/plugins/ingest_manager/server/errors/handlers.test.ts +++ b/x-pack/plugins/ingest_manager/server/errors/handlers.test.ts @@ -13,6 +13,7 @@ import { IngestManagerError, RegistryError, PackageNotFoundError, + PackageUnsupportedMediaTypeError, defaultIngestErrorHandler, } from './index'; @@ -101,6 +102,25 @@ describe('defaultIngestErrorHandler', () => { expect(mockContract.logger?.error).toHaveBeenCalledWith(error.message); }); + it('415: PackageUnsupportedMediaType', async () => { + const error = new PackageUnsupportedMediaTypeError('123'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 415, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith(error.message); + }); + it('404: PackageNotFoundError', async () => { const error = new PackageNotFoundError('123'); const response = httpServerMock.createResponseFactory(); diff --git a/x-pack/plugins/ingest_manager/server/errors/handlers.ts b/x-pack/plugins/ingest_manager/server/errors/handlers.ts index b621f2dd29331..bcad3f9c022da 100644 --- a/x-pack/plugins/ingest_manager/server/errors/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/errors/handlers.ts @@ -13,7 +13,12 @@ import { } from 'src/core/server'; import { errors as LegacyESErrors } from 'elasticsearch'; import { appContextService } from '../services'; -import { IngestManagerError, RegistryError, PackageNotFoundError } from './index'; +import { + IngestManagerError, + RegistryError, + PackageNotFoundError, + PackageUnsupportedMediaTypeError, +} from './index'; type IngestErrorHandler = ( params: IngestErrorHandlerParams @@ -52,6 +57,9 @@ const getHTTPResponseCode = (error: IngestManagerError): number => { if (error instanceof PackageNotFoundError) { return 404; // Not Found } + if (error instanceof PackageUnsupportedMediaTypeError) { + return 415; // Unsupported Media Type + } return 400; // Bad Request }; diff --git a/x-pack/plugins/ingest_manager/server/errors/index.ts b/x-pack/plugins/ingest_manager/server/errors/index.ts index f495bf551dcff..15ac97f21a17a 100644 --- a/x-pack/plugins/ingest_manager/server/errors/index.ts +++ b/x-pack/plugins/ingest_manager/server/errors/index.ts @@ -18,3 +18,7 @@ export class RegistryConnectionError extends RegistryError {} export class RegistryResponseError extends RegistryError {} export class PackageNotFoundError extends IngestManagerError {} export class PackageOutdatedError extends IngestManagerError {} +export class PackageUnsupportedMediaTypeError extends IngestManagerError {} +export class PackageInvalidArchiveError extends IngestManagerError {} +export class PackageCacheError extends IngestManagerError {} +export class PackageOperationNotSupportedError extends IngestManagerError {} diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index fb867af513fdc..5e075cbbcdf5e 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -239,6 +239,7 @@ export const getAgentsHandler: RequestHandler< page: request.query.page, perPage: request.query.perPage, showInactive: request.query.showInactive, + showUpgradeable: request.query.showUpgradeable, kuery: request.query.kuery, }); const totalInactive = request.query.showInactive diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index 73ed276ba02e7..ea8981bbfbad5 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -11,7 +11,12 @@ import { IRouter, RouteValidationResultFactory } from 'src/core/server'; import Ajv from 'ajv'; -import { PLUGIN_ID, AGENT_API_ROUTES, LIMITED_CONCURRENCY_ROUTE_TAG } from '../../constants'; +import { + PLUGIN_ID, + AGENT_API_ROUTES, + LIMITED_CONCURRENCY_ROUTE_TAG, + AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS, +} from '../../constants'; import { GetAgentsRequestSchema, GetOneAgentRequestSchema, @@ -30,6 +35,7 @@ import { PostBulkAgentReassignRequestSchema, PostAgentEnrollRequestBodyJSONSchema, PostAgentUpgradeRequestSchema, + PostBulkAgentUpgradeRequestSchema, } from '../../types'; import { getAgentsHandler, @@ -49,7 +55,7 @@ import { postNewAgentActionHandlerBuilder } from './actions_handlers'; import { appContextService } from '../../services'; import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler'; import { IngestManagerConfigType } from '../..'; -import { postAgentUpgradeHandler } from './upgrade_handler'; +import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler'; const ajv = new Ajv({ coerceTypes: true, @@ -123,7 +129,8 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) }, options: { tags: [], - ...(pollingRequestTimeout + // If the timeout is too short, do not set socket idle timeout and rely on Kibana global socket timeout + ...(pollingRequestTimeout && pollingRequestTimeout > AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS ? { timeout: { idleSocket: pollingRequestTimeout, @@ -226,6 +233,15 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) }, postAgentUpgradeHandler ); + // bulk upgrade + router.post( + { + path: AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, + validate: PostBulkAgentUpgradeRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postBulkAgentsUpgradeHandler + ); // Bulk reassign router.post( { diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts index e5d7a44c00768..c4aa33999cf22 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts @@ -6,11 +6,16 @@ import { RequestHandler } from 'src/core/server'; import { TypeOf } from '@kbn/config-schema'; -import { PostAgentUpgradeResponse } from '../../../common/types'; -import { PostAgentUpgradeRequestSchema } from '../../types'; +import { + AgentSOAttributes, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, +} from '../../../common/types'; +import { PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; import { defaultIngestErrorHandler } from '../../errors'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; export const postAgentUpgradeHandler: RequestHandler< TypeOf, @@ -30,6 +35,18 @@ export const postAgentUpgradeHandler: RequestHandler< }, }); } + const agent = await soClient.get( + AGENT_SAVED_OBJECT_TYPE, + request.params.agentId + ); + if (agent.attributes.unenrollment_started_at || agent.attributes.unenrolled_at) { + return response.customError({ + statusCode: 400, + body: { + message: `cannot upgrade an unenrolling or unenrolled agent`, + }, + }); + } try { await AgentService.sendUpgradeAgentAction({ @@ -45,3 +62,44 @@ export const postAgentUpgradeHandler: RequestHandler< return defaultIngestErrorHandler({ error, response }); } }; + +export const postBulkAgentsUpgradeHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const { version, source_uri: sourceUri, agents } = request.body; + + // temporarily only allow upgrading to the same version as the installed kibana version + const kibanaVersion = appContextService.getKibanaVersion(); + if (kibanaVersion !== version) { + return response.customError({ + statusCode: 400, + body: { + message: `cannot upgrade agent to ${version} because it is different than the installed kibana version ${kibanaVersion}`, + }, + }); + } + + try { + if (Array.isArray(agents)) { + await AgentService.sendUpgradeAgentsActions(soClient, { + agentIds: agents, + sourceUri, + version, + }); + } else { + await AgentService.sendUpgradeAgentsActions(soClient, { + kuery: agents, + sourceUri, + version, + }); + } + + const body: PostBulkAgentUpgradeResponse = {}; + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts index c55979d187f9d..0aa8641fd2a3e 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -8,7 +8,6 @@ import { RequestHandler, CustomHttpResponseOptions } from 'src/core/server'; import { GetInfoResponse, InstallPackageResponse, - MessageResponse, DeletePackageResponse, GetCategoriesResponse, GetPackagesResponse, @@ -35,8 +34,9 @@ import { getFile, getPackageInfo, handleInstallPackageFailure, - installPackage, isBulkInstallError, + installPackageFromRegistry, + installPackageByUpload, removeInstallation, getLimitedPackages, getInstallationObject, @@ -148,7 +148,7 @@ export const installPackageFromRegistryHandler: RequestHandler< const { pkgName, pkgVersion } = splitPkgKey(pkgkey); const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); try { - const res = await installPackage({ + const res = await installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster, @@ -212,10 +212,24 @@ export const installPackageByUploadHandler: RequestHandler< undefined, TypeOf > = async (context, request, response) => { - const body: MessageResponse = { - response: 'package upload was received ok, but not installed (not implemented yet)', - }; - return response.ok({ body }); + const savedObjectsClient = context.core.savedObjects.client; + const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; + const contentType = request.headers['content-type'] as string; // from types it could also be string[] or undefined but this is checked later + const archiveBuffer = Buffer.from(request.body); + try { + const res = await installPackageByUpload({ + savedObjectsClient, + callCluster, + archiveBuffer, + contentType, + }); + const body: InstallPackageResponse = { + response: res, + }; + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } }; export const deletePackageHandler: RequestHandler, + Installation +> = (installationDoc) => { + installationDoc.attributes.install_source = 'registry'; + + return installationDoc; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts index 8ae151577fefa..2481655ccdc6f 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts @@ -167,6 +167,12 @@ export async function createAgentActionFromPolicyAction( function getPollingTimeoutMs() { const pollingTimeoutMs = appContextService.getConfig()?.fleet.pollingRequestTimeout ?? 0; + + // If polling timeout is too short do not use margin + if (pollingTimeoutMs <= AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS) { + return pollingTimeoutMs; + } + // Set a timeout 20s before the real timeout to have a chance to respond an empty response before socket timeout return Math.max( pollingTimeoutMs - AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS, diff --git a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts index c941b0512e597..90db6c4b17713 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts @@ -5,10 +5,12 @@ */ import Boom from 'boom'; import { SavedObjectsClientContract } from 'src/core/server'; +import { isAgentUpgradeable } from '../../../common'; import { AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../constants'; import { AgentSOAttributes, Agent, AgentEventSOAttributes, ListWithKuery } from '../../types'; import { escapeSearchQueryPhrase, normalizeKuery, findAllSOs } from '../saved_object'; import { savedObjectToAgent } from './saved_objects'; +import { appContextService } from '../../services'; const ACTIVE_AGENT_CONDITION = `${AGENT_SAVED_OBJECT_TYPE}.attributes.active:true`; const INACTIVE_AGENT_CONDITION = `NOT (${ACTIVE_AGENT_CONDITION})`; @@ -41,6 +43,7 @@ export async function listAgents( sortOrder = 'desc', kuery, showInactive = false, + showUpgradeable, } = options; const filters = []; @@ -52,7 +55,7 @@ export async function listAgents( filters.push(ACTIVE_AGENT_CONDITION); } - const { saved_objects: agentSOs, total } = await soClient.find({ + let { saved_objects: agentSOs, total } = await soClient.find({ type: AGENT_SAVED_OBJECT_TYPE, filter: _joinFilters(filters), sortField, @@ -60,6 +63,14 @@ export async function listAgents( page, perPage, }); + // filtering for a range on the version string will not work, + // nor does filtering on a flattened field (local_metadata), so filter here + if (showUpgradeable) { + agentSOs = agentSOs.filter((agent) => + isAgentUpgradeable(savedObjectToAgent(agent), appContextService.getKibanaVersion()) + ); + total = agentSOs.length; + } return { agents: agentSOs.map(savedObjectToAgent), diff --git a/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts b/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts index cee3bc69f25db..612ebf9c11ab3 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts @@ -7,7 +7,8 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { AgentSOAttributes, AgentAction, AgentActionSOAttributes } from '../../types'; import { AGENT_ACTION_SAVED_OBJECT_TYPE, AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { createAgentAction } from './actions'; +import { bulkCreateAgentActions, createAgentAction } from './actions'; +import { getAgents, listAllAgents } from './crud'; export async function sendUpgradeAgentAction({ soClient, @@ -18,7 +19,7 @@ export async function sendUpgradeAgentAction({ soClient: SavedObjectsClientContract; agentId: string; version: string; - sourceUri: string; + sourceUri: string | undefined; }) { const now = new Date().toISOString(); const data = { @@ -50,12 +51,62 @@ export async function ackAgentUpgraded( if (!version) throw new Error('version missing from UPGRADE action'); await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentAction.agent_id, { upgraded_at: new Date().toISOString(), - local_metadata: { - elastic: { - agent: { - version, - }, - }, - }, + upgrade_started_at: undefined, }); } + +export async function sendUpgradeAgentsActions( + soClient: SavedObjectsClientContract, + options: + | { + agentIds: string[]; + sourceUri: string | undefined; + version: string; + } + | { + kuery: string; + sourceUri: string | undefined; + version: string; + } +) { + // Filter out agents currently unenrolling, agents unenrolled + const agents = + 'agentIds' in options + ? await getAgents(soClient, options.agentIds) + : ( + await listAllAgents(soClient, { + kuery: options.kuery, + showInactive: false, + }) + ).agents; + const agentsToUpdate = agents.filter( + (agent) => !agent.unenrollment_started_at && !agent.unenrolled_at + ); + const now = new Date().toISOString(); + const data = { + version: options.version, + source_uri: options.sourceUri, + }; + // Create upgrade action for each agent + await bulkCreateAgentActions( + soClient, + agentsToUpdate.map((agent) => ({ + agent_id: agent.id, + created_at: now, + data, + ack_data: data, + type: 'UPGRADE', + })) + ); + + return await soClient.bulkUpdate( + agentsToUpdate.map((agent) => ({ + type: AGENT_SAVED_OBJECT_TYPE, + id: agent.id, + attributes: { + upgraded_at: undefined, + upgrade_started_at: now, + }, + })) + ); +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts new file mode 100644 index 0000000000000..91ed489b3a5bb --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import yaml from 'js-yaml'; +import { uniq } from 'lodash'; + +import { + ArchivePackage, + RegistryPolicyTemplate, + RegistryDataStream, + RegistryInput, + RegistryStream, + RegistryVarsEntry, +} from '../../../../common/types'; +import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError } from '../../../errors'; +import { pkgToPkgKey } from '../registry'; +import { cacheGet, cacheSet, setArchiveFilelist } from '../registry/cache'; +import { unzipBuffer, untarBuffer, ArchiveEntry } from '../registry/extract'; + +export async function loadArchivePackage({ + archiveBuffer, + contentType, +}: { + archiveBuffer: Buffer; + contentType: string; +}): Promise<{ paths: string[]; archivePackageInfo: ArchivePackage }> { + const paths = await unpackArchiveToCache(archiveBuffer, contentType); + const archivePackageInfo = parseAndVerifyArchive(paths); + setArchiveFilelist(archivePackageInfo.name, archivePackageInfo.version, paths); + + return { + paths, + archivePackageInfo, + }; +} + +function getBufferExtractorForContentType(contentType: string) { + if (contentType === 'application/gzip') { + return untarBuffer; + } else if (contentType === 'application/zip') { + return unzipBuffer; + } else { + throw new PackageUnsupportedMediaTypeError( + `Unsupported media type ${contentType}. Please use 'application/gzip' or 'application/zip'` + ); + } +} + +export async function unpackArchiveToCache( + archiveBuffer: Buffer, + contentType: string, + filter = (entry: ArchiveEntry): boolean => true +): Promise { + const bufferExtractor = getBufferExtractorForContentType(contentType); + const paths: string[] = []; + try { + await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { + const { path, buffer } = entry; + // skip directories + if (path.slice(-1) === '/') return; + if (buffer) { + cacheSet(path, buffer); + paths.push(path); + } + }); + } catch (error) { + throw new PackageInvalidArchiveError( + `Error during extraction of uploaded package: ${error}. Assumed content type was ${contentType}, check if this matches the archive type.` + ); + } + + // While unpacking a tar.gz file with unzipBuffer() will result in a thrown error in the try-catch above, + // unpacking a zip file with untarBuffer() just results in nothing. + if (paths.length === 0) { + throw new PackageInvalidArchiveError( + `Uploaded archive seems empty. Assumed content type was ${contentType}, check if this matches the archive type.` + ); + } + return paths; +} + +// TODO: everything below performs verification of manifest.yml files, and hence duplicates functionality already implemented in the +// package registry. At some point this should probably be replaced (or enhanced) with verification based on +// https://github.com/elastic/package-spec/ + +function parseAndVerifyArchive(paths: string[]): ArchivePackage { + // The top-level directory must match pkgName-pkgVersion, and no other top-level files or directories may be present + const toplevelDir = paths[0].split('/')[0]; + paths.forEach((path) => { + if (path.split('/')[0] !== toplevelDir) { + throw new PackageInvalidArchiveError('Package contains more than one top-level directory.'); + } + }); + + // The package must contain a manifest file ... + const manifestFile = `${toplevelDir}/manifest.yml`; + const manifestBuffer = cacheGet(manifestFile); + if (!paths.includes(manifestFile) || !manifestBuffer) { + throw new PackageInvalidArchiveError('Package must contain a top-level manifest.yml file.'); + } + + // ... which must be valid YAML + let manifest; + try { + manifest = yaml.load(manifestBuffer.toString()); + } catch (error) { + throw new PackageInvalidArchiveError(`Could not parse top-level package manifest: ${error}.`); + } + + // Package name and version from the manifest must match those from the toplevel directory + const pkgKey = pkgToPkgKey({ name: manifest.name, version: manifest.version }); + if (toplevelDir !== pkgKey) { + throw new PackageInvalidArchiveError( + `Name ${manifest.name} and version ${manifest.version} do not match top-level directory ${toplevelDir}` + ); + } + + const { name, version, description, type, categories, format_version: formatVersion } = manifest; + // check for mandatory fields + if (!(name && version && description && type && categories && formatVersion)) { + throw new PackageInvalidArchiveError( + 'Invalid top-level package manifest: one or more fields missing of name, version, description, type, categories, format_version' + ); + } + + const dataStreams = parseAndVerifyDataStreams(paths, name, version); + const policyTemplates = parseAndVerifyPolicyTemplates(manifest); + + return { + name, + version, + description, + type, + categories, + format_version: formatVersion, + data_streams: dataStreams, + policy_templates: policyTemplates, + }; +} + +function parseAndVerifyDataStreams( + paths: string[], + pkgName: string, + pkgVersion: string +): RegistryDataStream[] { + // A data stream is made up of a subdirectory of name-version/data_stream/, containing a manifest.yml + let dataStreamPaths: string[] = []; + const dataStreams: RegistryDataStream[] = []; + const pkgKey = pkgToPkgKey({ name: pkgName, version: pkgVersion }); + + // pick all paths matching name-version/data_stream/DATASTREAM_PATH/... + // from those, pick all unique data stream paths + paths + .filter((path) => path.startsWith(`${pkgKey}/data_stream/`)) + .forEach((path) => { + const parts = path.split('/'); + if (parts.length > 2 && parts[2]) dataStreamPaths.push(parts[2]); + }); + + dataStreamPaths = uniq(dataStreamPaths); + + dataStreamPaths.forEach((dataStreamPath) => { + const manifestFile = `${pkgKey}/data_stream/${dataStreamPath}/manifest.yml`; + const manifestBuffer = cacheGet(manifestFile); + if (!paths.includes(manifestFile) || !manifestBuffer) { + throw new PackageInvalidArchiveError( + `No manifest.yml file found for data stream '${dataStreamPath}'` + ); + } + + let manifest; + try { + manifest = yaml.load(manifestBuffer.toString()); + } catch (error) { + throw new PackageInvalidArchiveError( + `Could not parse package manifest for data stream '${dataStreamPath}': ${error}.` + ); + } + + const { + title: dataStreamTitle, + release, + ingest_pipeline: ingestPipeline, + type, + dataset, + } = manifest; + if (!(dataStreamTitle && release && type)) { + throw new PackageInvalidArchiveError( + `Invalid manifest for data stream '${dataStreamPath}': one or more fields missing of 'title', 'release', 'type'` + ); + } + const streams = parseAndVerifyStreams(manifest, dataStreamPath); + + // default ingest pipeline name see https://github.com/elastic/package-registry/blob/master/util/dataset.go#L26 + return dataStreams.push({ + dataset: dataset || `${pkgName}.${dataStreamPath}`, + title: dataStreamTitle, + release, + package: pkgName, + ingest_pipeline: ingestPipeline || 'default', + path: dataStreamPath, + type, + streams, + }); + }); + + return dataStreams; +} + +function parseAndVerifyStreams(manifest: any, dataStreamPath: string): RegistryStream[] { + const streams: RegistryStream[] = []; + const manifestStreams = manifest.streams; + if (manifestStreams && manifestStreams.length > 0) { + manifestStreams.forEach((manifestStream: any) => { + const { + input, + title: streamTitle, + description, + enabled, + vars: manifestVars, + template_path: templatePath, + } = manifestStream; + if (!(input && streamTitle)) { + throw new PackageInvalidArchiveError( + `Invalid manifest for data stream ${dataStreamPath}: stream is missing one or more fields of: input, title` + ); + } + const vars = parseAndVerifyVars(manifestVars, `data stream ${dataStreamPath}`); + // default template path name see https://github.com/elastic/package-registry/blob/master/util/dataset.go#L143 + streams.push({ + input, + title: streamTitle, + description, + enabled, + vars, + template_path: templatePath || 'stream.yml.hbs', + }); + }); + } + return streams; +} + +function parseAndVerifyVars(manifestVars: any[], location: string): RegistryVarsEntry[] { + const vars: RegistryVarsEntry[] = []; + if (manifestVars && manifestVars.length > 0) { + manifestVars.forEach((manifestVar) => { + const { + name, + title: varTitle, + description, + type, + required, + show_user: showUser, + multi, + def, + os, + } = manifestVar; + if (!(name && type)) { + throw new PackageInvalidArchiveError( + `Invalid var definition for ${location}: one of mandatory fields 'name' and 'type' missing in var: ${manifestVar}` + ); + } + vars.push({ + name, + title: varTitle, + description, + type, + required, + show_user: showUser, + multi, + default: def, + os, + }); + }); + } + return vars; +} + +function parseAndVerifyPolicyTemplates(manifest: any): RegistryPolicyTemplate[] { + const policyTemplates: RegistryPolicyTemplate[] = []; + const manifestPolicyTemplates = manifest.policy_templates; + if (manifestPolicyTemplates && manifestPolicyTemplates > 0) { + manifestPolicyTemplates.forEach((policyTemplate: any) => { + const { name, title: policyTemplateTitle, description, inputs, multiple } = policyTemplate; + if (!(name && policyTemplateTitle && description && inputs)) { + throw new PackageInvalidArchiveError( + `Invalid top-level manifest: one of mandatory fields 'name', 'title', 'description', 'input' missing in policy template: ${policyTemplate}` + ); + } + + const parsedInputs = parseAndVerifyInputs(inputs, `config template ${name}`); + + // defaults to true if undefined, but may be explicitly set to false. + let parsedMultiple = true; + if (typeof multiple === 'boolean' && multiple === false) parsedMultiple = false; + + policyTemplates.push({ + name, + title: policyTemplateTitle, + description, + inputs: parsedInputs, + multiple: parsedMultiple, + }); + }); + } + return policyTemplates; +} + +function parseAndVerifyInputs(manifestInputs: any, location: string): RegistryInput[] { + const inputs: RegistryInput[] = []; + if (manifestInputs && manifestInputs.length > 0) { + manifestInputs.forEach((input: any) => { + const { type, title: inputTitle, description, vars } = input; + if (!(type && inputTitle)) { + throw new PackageInvalidArchiveError( + `Invalid top-level manifest: one of mandatory fields 'type', 'title' missing in input: ${input}` + ); + } + const parsedVars = parseAndVerifyVars(vars, location); + inputs.push({ + type, + title: inputTitle, + description, + vars: parsedVars, + }); + }); + } + return inputs; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts index 6088bcb71f878..43c0179c0aa8a 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -9,7 +9,7 @@ import { EsAssetReference, RegistryDataStream, ElasticsearchAssetType, - RegistryPackage, + InstallablePackage, } from '../../../../types'; import * as Registry from '../../registry'; import { CallESAsCurrentUser } from '../../../../types'; @@ -22,7 +22,7 @@ interface RewriteSubstitution { } export const installPipelines = async ( - registryPackage: RegistryPackage, + installablePackage: InstallablePackage, paths: string[], callCluster: CallESAsCurrentUser, savedObjectsClient: SavedObjectsClientContract @@ -30,7 +30,7 @@ export const installPipelines = async ( // unlike other ES assets, pipeline names are versioned so after a template is updated // it can be created pointing to the new template, without removing the old one and effecting data // so do not remove the currently installed pipelines here - const dataStreams = registryPackage.data_streams; + const dataStreams = installablePackage.data_streams; if (!dataStreams?.length) return []; const pipelinePaths = paths.filter((path) => isPipeline(path)); // get and save pipeline refs before installing pipelines @@ -43,14 +43,14 @@ export const installPipelines = async ( const nameForInstallation = getPipelineNameForInstallation({ pipelineName: name, dataStream, - packageVersion: registryPackage.version, + packageVersion: installablePackage.version, }); return { id: nameForInstallation, type: ElasticsearchAssetType.ingestPipeline }; }); acc.push(...pipelineObjectRefs); return acc; }, []); - await saveInstalledEsRefs(savedObjectsClient, registryPackage.name, pipelineRefs); + await saveInstalledEsRefs(savedObjectsClient, installablePackage.name, pipelineRefs); const pipelines = dataStreams.reduce>>((acc, dataStream) => { if (dataStream.ingest_pipeline) { acc.push( @@ -58,7 +58,7 @@ export const installPipelines = async ( dataStream, callCluster, paths: pipelinePaths, - pkgVersion: registryPackage.version, + pkgVersion: installablePackage.version, }) ); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts index 8f80feb268910..d32d5b8093c52 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts @@ -8,10 +8,10 @@ import Boom from 'boom'; import { SavedObjectsClientContract } from 'src/core/server'; import { RegistryDataStream, - RegistryPackage, ElasticsearchAssetType, TemplateRef, RegistryElasticsearch, + InstallablePackage, } from '../../../../types'; import { CallESAsCurrentUser } from '../../../../types'; import { Field, loadFieldsFromYaml, processFields } from '../../fields/field'; @@ -21,7 +21,7 @@ import * as Registry from '../../registry'; import { removeAssetsFromInstalledEsByType, saveInstalledEsRefs } from '../../packages/install'; export const installTemplates = async ( - registryPackage: RegistryPackage, + installablePackage: InstallablePackage, callCluster: CallESAsCurrentUser, paths: string[], savedObjectsClient: SavedObjectsClientContract @@ -35,11 +35,11 @@ export const installTemplates = async ( // remove package installation's references to index templates await removeAssetsFromInstalledEsByType( savedObjectsClient, - registryPackage.name, + installablePackage.name, ElasticsearchAssetType.indexTemplate ); // build templates per data stream from yml files - const dataStreams = registryPackage.data_streams; + const dataStreams = installablePackage.data_streams; if (!dataStreams) return []; // get template refs to save const installedTemplateRefs = dataStreams.map((dataStream) => ({ @@ -48,14 +48,14 @@ export const installTemplates = async ( })); // add package installation's references to index templates - await saveInstalledEsRefs(savedObjectsClient, registryPackage.name, installedTemplateRefs); + await saveInstalledEsRefs(savedObjectsClient, installablePackage.name, installedTemplateRefs); if (dataStreams) { const installTemplatePromises = dataStreams.reduce>>( (acc, dataStream) => { acc.push( installTemplateForDataStream({ - pkg: registryPackage, + pkg: installablePackage, callCluster, dataStream, }) @@ -171,7 +171,7 @@ export async function installTemplateForDataStream({ callCluster, dataStream, }: { - pkg: RegistryPackage; + pkg: InstallablePackage; callCluster: CallESAsCurrentUser; dataStream: RegistryDataStream; }): Promise { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts index d8aff10492595..89811783a7f79 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts @@ -11,7 +11,7 @@ import * as Registry from '../../registry'; import { ElasticsearchAssetType, EsAssetReference, - RegistryPackage, + InstallablePackage, } from '../../../../../common/types/models'; import { CallESAsCurrentUser } from '../../../../types'; import { getInstallation } from '../../packages'; @@ -24,14 +24,14 @@ interface TransformInstallation { } export const installTransform = async ( - registryPackage: RegistryPackage, + installablePackage: InstallablePackage, paths: string[], callCluster: CallESAsCurrentUser, savedObjectsClient: SavedObjectsClientContract ) => { const installation = await getInstallation({ savedObjectsClient, - pkgName: registryPackage.name, + pkgName: installablePackage.name, }); let previousInstalledTransformEsAssets: EsAssetReference[] = []; if (installation) { @@ -46,13 +46,13 @@ export const installTransform = async ( previousInstalledTransformEsAssets.map((asset) => asset.id) ); - const installNameSuffix = `${registryPackage.version}`; + const installNameSuffix = `${installablePackage.version}`; const transformPaths = paths.filter((path) => isTransform(path)); let installedTransforms: EsAssetReference[] = []; if (transformPaths.length > 0) { const transformRefs = transformPaths.reduce((acc, path) => { acc.push({ - id: getTransformNameForInstallation(registryPackage, path, installNameSuffix), + id: getTransformNameForInstallation(installablePackage, path, installNameSuffix), type: ElasticsearchAssetType.transform, }); @@ -60,11 +60,15 @@ export const installTransform = async ( }, []); // get and save transform refs before installing transforms - await saveInstalledEsRefs(savedObjectsClient, registryPackage.name, transformRefs); + await saveInstalledEsRefs(savedObjectsClient, installablePackage.name, transformRefs); const transforms: TransformInstallation[] = transformPaths.map((path: string) => { return { - installationName: getTransformNameForInstallation(registryPackage, path, installNameSuffix), + installationName: getTransformNameForInstallation( + installablePackage, + path, + installNameSuffix + ), content: getAsset(path).toString('utf-8'), }; }); @@ -79,14 +83,14 @@ export const installTransform = async ( if (previousInstalledTransformEsAssets.length > 0) { const currentInstallation = await getInstallation({ savedObjectsClient, - pkgName: registryPackage.name, + pkgName: installablePackage.name, }); // remove the saved object reference await deleteTransformRefs( savedObjectsClient, currentInstallation?.installed_es || [], - registryPackage.name, + installablePackage.name, previousInstalledTransformEsAssets.map((asset) => asset.id), installedTransforms.map((installed) => installed.id) ); @@ -123,12 +127,12 @@ async function handleTransformInstall({ } const getTransformNameForInstallation = ( - registryPackage: RegistryPackage, + installablePackage: InstallablePackage, path: string, suffix: string ) => { const pathPaths = path.split('/'); const filename = pathPaths?.pop()?.split('.')[0]; const folderName = pathPaths?.pop(); - return `${registryPackage.name}.${folderName}-${filename}-${suffix}`; + return `${installablePackage.name}.${folderName}-${filename}-${suffix}`; }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts index 5913302e77ba6..06091ab3cedb0 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts @@ -5,7 +5,7 @@ */ import { safeLoad } from 'js-yaml'; -import { RegistryPackage } from '../../../types'; +import { InstallablePackage } from '../../../types'; import { getAssetsData } from '../packages/assets'; // This should become a copy of https://github.com/elastic/beats/blob/d9a4c9c240a9820fab15002592e5bb6db318543b/libbeat/mapping/field.go#L39 @@ -253,7 +253,7 @@ const isFields = (path: string) => { */ export const loadFieldsFromYaml = async ( - pkg: RegistryPackage, + pkg: InstallablePackage, datasetName?: string ): Promise => { // Fetch all field definition files diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts index bde542412f123..2aa28d23cf857 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts @@ -89,7 +89,9 @@ export async function installIndexPatterns( // TODO: move to install package // cache all installed packages if they don't exist const packagePromises = installedPackages.map((pkg) => - Registry.ensureCachedArchiveInfo(pkg.pkgName, pkg.pkgVersion) + // TODO: this hard-codes 'registry' as installSource, so uploaded packages are ignored + // and their fields will be removed from the generated index patterns after this runs. + Registry.ensureCachedArchiveInfo(pkg.pkgName, pkg.pkgVersion, 'registry') ); await Promise.all(packagePromises); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts index 78b42b03be831..eb43bef72db70 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts @@ -4,34 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RegistryPackage } from '../../../types'; +import { InstallablePackage } from '../../../types'; import { getAssets } from './assets'; +import { getArchiveFilelist } from '../registry/cache'; + +jest.mock('../registry/cache', () => { + return { + getArchiveFilelist: jest.fn(), + }; +}); + +const mockedGetArchiveFilelist = getArchiveFilelist as jest.Mock; +mockedGetArchiveFilelist.mockImplementation(() => [ + 'coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', + 'coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', +]); const tests = [ { package: { - assets: [ - '/package/coredns/1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', - '/package/coredns/1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', - ], - path: '/package/coredns/1.0.1', + name: 'coredns', + version: '1.0.1', }, dataset: 'log', filter: (path: string) => { return true; }, expected: [ - '/package/coredns/1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', - '/package/coredns/1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', + 'coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', + 'coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', ], }, { package: { - assets: [ - '/package/coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', - '/package/coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', - ], - path: '/package/coredns/1.0.1', + name: 'coredns', + version: '1.0.1', }, // Non existant dataset dataset: 'foo', @@ -42,10 +49,8 @@ const tests = [ }, { package: { - assets: [ - '/package/coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', - '/package/coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', - ], + name: 'coredns', + version: '1.0.1', }, // Filter which does not exist filter: (path: string) => { @@ -57,8 +62,8 @@ const tests = [ test('testGetAssets', () => { for (const value of tests) { - // as needed to pretent it is a RegistryPackage - const assets = getAssets(value.package as RegistryPackage, value.filter, value.dataset); + // as needed to pretend it is an InstallablePackage + const assets = getAssets(value.package as InstallablePackage, value.filter, value.dataset); expect(assets).toStrictEqual(value.expected); } }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts index a8abc12917781..856f04c0c9b67 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RegistryPackage } from '../../../types'; +import { InstallablePackage } from '../../../types'; import * as Registry from '../registry'; -import { ensureCachedArchiveInfo } from '../registry'; +import { getArchiveFilelist } from '../registry/cache'; // paths from RegistryPackage are routes to the assets on EPR // e.g. `/package/nginx/1.2.0/data_stream/access/fields/fields.yml` @@ -14,30 +14,26 @@ import { ensureCachedArchiveInfo } from '../registry'; // e.g. `nginx-1.2.0/data_stream/access/fields/fields.yml` // RegistryPackage paths have a `/package/` prefix compared to ArchiveEntry paths // and different package and version structure -const EPR_PATH_PREFIX = '/package'; -function registryPathToArchivePath(registryPath: RegistryPackage['path']): string { - const path = registryPath.replace(`${EPR_PATH_PREFIX}/`, ''); - const [pkgName, pkgVersion] = path.split('/'); - return path.replace(`${pkgName}/${pkgVersion}`, `${pkgName}-${pkgVersion}`); -} export function getAssets( - packageInfo: RegistryPackage, + packageInfo: InstallablePackage, filter = (path: string): boolean => true, datasetName?: string ): string[] { const assets: string[] = []; - if (!packageInfo?.assets) return assets; + const paths = getArchiveFilelist(packageInfo.name, packageInfo.version); + // TODO: might be better to throw a PackageCacheError here + if (!paths || paths.length === 0) return assets; // Skip directories - for (const path of packageInfo.assets) { + for (const path of paths) { if (path.endsWith('/')) { continue; } // if dataset, filter for them if (datasetName) { - const comparePath = `${packageInfo.path}/data_stream/${datasetName}/`; + const comparePath = `${packageInfo.name}-${packageInfo.version}/data_stream/${datasetName}/`; if (!path.includes(comparePath)) { continue; } @@ -52,21 +48,20 @@ export function getAssets( } export async function getAssetsData( - packageInfo: RegistryPackage, + packageInfo: InstallablePackage, filter = (path: string): boolean => true, datasetName?: string ): Promise { // TODO: Needs to be called to fill the cache but should not be required - await ensureCachedArchiveInfo(packageInfo.name, packageInfo.version); + await Registry.ensureCachedArchiveInfo(packageInfo.name, packageInfo.version, 'registry'); // Gather all asset data const assets = getAssets(packageInfo, filter, datasetName); - const entries: Registry.ArchiveEntry[] = assets.map((registryPath) => { - const archivePath = registryPathToArchivePath(registryPath); - const buffer = Registry.getAsset(archivePath); + const entries: Registry.ArchiveEntry[] = assets.map((path) => { + const buffer = Registry.getAsset(path); - return { path: registryPath, buffer }; + return { path, buffer }; }); return entries; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts index f0b487ad59774..aaff5df39bac3 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts @@ -49,6 +49,7 @@ const mockInstallation: SavedObject = { install_status: 'installed', install_version: '1.0.0', install_started_at: new Date().toISOString(), + install_source: 'registry', }, }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index 2d11b6157804f..74ee25eace736 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -101,11 +101,14 @@ export async function getPackageInfo(options: { pkgVersion: string; }): Promise { const { savedObjectsClient, pkgName, pkgVersion } = options; - const [item, savedObject, latestPackage, assets] = await Promise.all([ - Registry.fetchInfo(pkgName, pkgVersion), + const [ + savedObject, + latestPackage, + { paths: assets, registryPackageInfo: item }, + ] = await Promise.all([ getInstallationObject({ savedObjectsClient, pkgName }), Registry.fetchFindLatestPackage(pkgName), - Registry.getArchiveInfo(pkgName, pkgVersion), + Registry.loadRegistryPackage(pkgName, pkgVersion), ]); // add properties that aren't (or aren't yet) on Registry response diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts index cce4b7fee8fd7..a04bfaafe7570 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts @@ -21,6 +21,7 @@ const mockInstallation: SavedObject = { install_status: 'installed', install_version: '1.0.0', install_started_at: new Date().toISOString(), + install_source: 'registry', }, }; const mockInstallationUpdateFail: SavedObject = { @@ -37,6 +38,7 @@ const mockInstallationUpdateFail: SavedObject = { install_status: 'installing', install_version: '1.0.1', install_started_at: new Date().toISOString(), + install_source: 'registry', }, }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts index 94aa969c2d2b8..92070f3c2fafc 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -27,9 +27,10 @@ export { export { BulkInstallResponse, - handleInstallPackageFailure, - installPackage, IBulkInstallPackageError, + handleInstallPackageFailure, + installPackageFromRegistry, + installPackageByUpload, ensureInstalledPackage, } from './install'; export { removeInstallation } from './remove'; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index d7262ebb66b2e..a7514d1075d78 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -8,7 +8,7 @@ import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import semver from 'semver'; import Boom from 'boom'; import { UnwrapPromise } from '@kbn/utility-types'; -import { BulkInstallPackageInfo } from '../../../../common'; +import { BulkInstallPackageInfo, InstallablePackage, InstallSource } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE, MAX_TIME_COMPLETE_INSTALL } from '../../../constants'; import { AssetReference, @@ -42,10 +42,15 @@ import { } from '../kibana/assets/install'; import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; import { deleteKibanaSavedObjectsAssets, removeInstallation } from './remove'; -import { IngestManagerError, PackageOutdatedError } from '../../../errors'; +import { + IngestManagerError, + PackageOperationNotSupportedError, + PackageOutdatedError, +} from '../../../errors'; import { getPackageSavedObjects } from './get'; import { installTransform } from '../elasticsearch/transform/install'; import { appContextService } from '../../app_context'; +import { loadArchivePackage } from '../archive'; export async function installLatestPackage(options: { savedObjectsClient: SavedObjectsClientContract; @@ -59,7 +64,7 @@ export async function installLatestPackage(options: { name: latestPackage.name, version: latestPackage.version, }); - return installPackage({ savedObjectsClient, pkgkey, callCluster }); + return installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster }); } catch (err) { throw err; } @@ -155,7 +160,7 @@ export async function handleInstallPackageFailure({ } const prevVersion = `${pkgName}-${installedPkg.attributes.version}`; logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`); - await installPackage({ + await installPackageFromRegistry({ savedObjectsClient, pkgkey: prevVersion, callCluster, @@ -193,7 +198,7 @@ export async function upgradePackage({ }); try { - const assets = await installPackage({ savedObjectsClient, pkgkey, callCluster }); + const assets = await installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster }); return { name: pkgToUpgrade, newVersion: latestPkg.version, @@ -232,7 +237,7 @@ interface InstallPackageParams { force?: boolean; } -export async function installPackage({ +export async function installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster, @@ -254,12 +259,96 @@ export async function installPackage({ if (semver.lt(pkgVersion, latestPackage.version) && !force && !installOutOfDateVersionOk) { throw new PackageOutdatedError(`${pkgkey} is out-of-date and cannot be installed or updated`); } - const paths = await Registry.getArchiveInfo(pkgName, pkgVersion); - const registryPackageInfo = await Registry.fetchInfo(pkgName, pkgVersion); + + const { paths, registryPackageInfo } = await Registry.loadRegistryPackage(pkgName, pkgVersion); const removable = !isRequiredPackage(pkgName); const { internal = false } = registryPackageInfo; - const toSaveESIndexPatterns = generateESIndexPatterns(registryPackageInfo.data_streams); + const installSource = 'registry'; + + return installPackage({ + savedObjectsClient, + callCluster, + pkgName, + pkgVersion, + installedPkg, + paths, + removable, + internal, + packageInfo: registryPackageInfo, + installType, + installSource, + }); +} + +export async function installPackageByUpload({ + savedObjectsClient, + callCluster, + archiveBuffer, + contentType, +}: { + savedObjectsClient: SavedObjectsClientContract; + callCluster: CallESAsCurrentUser; + archiveBuffer: Buffer; + contentType: string; +}): Promise { + const { paths, archivePackageInfo } = await loadArchivePackage({ archiveBuffer, contentType }); + const installedPkg = await getInstallationObject({ + savedObjectsClient, + pkgName: archivePackageInfo.name, + }); + const installType = getInstallType({ pkgVersion: archivePackageInfo.version, installedPkg }); + if (installType !== 'install') { + throw new PackageOperationNotSupportedError( + `Package upload only supports fresh installations. Package ${archivePackageInfo.name} is already installed, please uninstall first.` + ); + } + + const removable = !isRequiredPackage(archivePackageInfo.name); + const { internal = false } = archivePackageInfo; + const installSource = 'upload'; + + return installPackage({ + savedObjectsClient, + callCluster, + pkgName: archivePackageInfo.name, + pkgVersion: archivePackageInfo.version, + installedPkg, + paths, + removable, + internal, + packageInfo: archivePackageInfo, + installType, + installSource, + }); +} + +async function installPackage({ + savedObjectsClient, + callCluster, + pkgName, + pkgVersion, + installedPkg, + paths, + removable, + internal, + packageInfo, + installType, + installSource, +}: { + savedObjectsClient: SavedObjectsClientContract; + callCluster: CallESAsCurrentUser; + pkgName: string; + pkgVersion: string; + installedPkg?: SavedObject; + paths: string[]; + removable: boolean; + internal: boolean; + packageInfo: InstallablePackage; + installType: InstallType; + installSource: InstallSource; +}): Promise { + const toSaveESIndexPatterns = generateESIndexPatterns(packageInfo.data_streams); // add the package installation to the saved object. // if some installation already exists, just update install info @@ -273,12 +362,14 @@ export async function installPackage({ installed_kibana: [], installed_es: [], toSaveESIndexPatterns, + installSource, }); } else { await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { install_version: pkgVersion, install_status: 'installing', install_started_at: new Date().toISOString(), + install_source: installSource, }); } const installIndexPatternPromise = installIndexPatterns(savedObjectsClient, pkgName, pkgVersion); @@ -309,14 +400,14 @@ export async function installPackage({ // installs versionized pipelines without removing currently installed ones const installedPipelines = await installPipelines( - registryPackageInfo, + packageInfo, paths, callCluster, savedObjectsClient ); // install or update the templates referencing the newly installed pipelines const installedTemplates = await installTemplates( - registryPackageInfo, + packageInfo, callCluster, paths, savedObjectsClient @@ -326,7 +417,7 @@ export async function installPackage({ await updateCurrentWriteIndices(callCluster, installedTemplates); const installedTransforms = await installTransform( - registryPackageInfo, + packageInfo, paths, callCluster, savedObjectsClient @@ -388,6 +479,7 @@ export async function createInstallation(options: { installed_kibana: KibanaAssetReference[]; installed_es: EsAssetReference[]; toSaveESIndexPatterns: Record; + installSource: InstallSource; }) { const { savedObjectsClient, @@ -398,6 +490,7 @@ export async function createInstallation(options: { installed_kibana: installedKibana, installed_es: installedEs, toSaveESIndexPatterns, + installSource, } = options; await savedObjectsClient.create( PACKAGES_SAVED_OBJECT_TYPE, @@ -412,6 +505,7 @@ export async function createInstallation(options: { install_version: pkgVersion, install_status: 'installing', install_started_at: new Date().toISOString(), + install_source: installSource, }, { id: pkgName, overwrite: true } ); @@ -477,7 +571,7 @@ export async function ensurePackagesCompletedInstall( const pkgkey = `${pkg.attributes.name}-${pkg.attributes.install_version}`; // reinstall package if (elapsedTime > MAX_TIME_COMPLETE_INSTALL) { - acc.push(installPackage({ savedObjectsClient, pkgkey, callCluster })); + acc.push(installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster })); } return acc; }, []); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts index 2434ebf27aa5d..417f2871a6cbf 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts @@ -18,7 +18,7 @@ import { deletePipeline } from '../elasticsearch/ingest_pipeline/'; import { installIndexPatterns } from '../kibana/index_pattern/install'; import { deleteTransforms } from '../elasticsearch/transform/remove'; import { packagePolicyService, appContextService } from '../..'; -import { splitPkgKey, deletePackageCache, getArchiveInfo } from '../registry'; +import { splitPkgKey, deletePackageCache } from '../registry'; export async function removeInstallation(options: { savedObjectsClient: SavedObjectsClientContract; @@ -57,8 +57,7 @@ export async function removeInstallation(options: { // remove the package archive and its contents from the cache so that a reinstall fetches // a fresh copy from the registry - const paths = await getArchiveInfo(pkgName, pkgVersion); - deletePackageCache(pkgName, pkgVersion, paths); + deletePackageCache(pkgName, pkgVersion); // successful delete's in SO client return {}. return something more useful return installedAssets; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts index b7c1e8c2069d6..695db9db73fa2 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts @@ -12,12 +12,12 @@ export const cacheHas = (key: string) => cache.has(key); export const cacheClear = () => cache.clear(); export const cacheDelete = (key: string) => cache.delete(key); -const archiveLocationCache: Map = new Map(); -export const getArchiveLocation = (name: string, version: string) => - archiveLocationCache.get(pkgToPkgKey({ name, version })); +const archiveFilelistCache: Map = new Map(); +export const getArchiveFilelist = (name: string, version: string) => + archiveFilelistCache.get(pkgToPkgKey({ name, version })); -export const setArchiveLocation = (name: string, version: string, location: string) => - archiveLocationCache.set(pkgToPkgKey({ name, version }), location); +export const setArchiveFilelist = (name: string, version: string, paths: string[]) => + archiveFilelistCache.set(pkgToPkgKey({ name, version }), paths); -export const deleteArchiveLocation = (name: string, version: string) => - archiveLocationCache.delete(pkgToPkgKey({ name, version })); +export const deleteArchiveFilelist = (name: string, version: string) => + archiveFilelistCache.delete(pkgToPkgKey({ name, version })); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts index 2fd9175549026..ba51636c13f36 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts @@ -6,17 +6,8 @@ import { AssetParts } from '../../../types'; import { getBufferExtractor, pathParts, splitPkgKey } from './index'; -import { getArchiveLocation } from './cache'; import { untarBuffer, unzipBuffer } from './extract'; -jest.mock('./cache', () => { - return { - getArchiveLocation: jest.fn(), - }; -}); - -const mockedGetArchiveLocation = getArchiveLocation as jest.Mock; - const testPaths = [ { path: 'foo-1.1.0/service/type/file.yml', @@ -92,19 +83,13 @@ describe('splitPkgKey tests', () => { }); describe('getBufferExtractor', () => { - it('throws if the archive has not been downloaded/cached yet', () => { - expect(() => getBufferExtractor('missing', '1.2.3')).toThrow('no archive location'); - }); - it('returns unzipBuffer if the archive key ends in .zip', () => { - mockedGetArchiveLocation.mockImplementation(() => '.zip'); - const extractor = getBufferExtractor('will-use-mocked-key', 'a.b.c'); + const extractor = getBufferExtractor('.zip'); expect(extractor).toBe(unzipBuffer); }); it('returns untarBuffer if the key ends in anything else', () => { - mockedGetArchiveLocation.mockImplementation(() => 'xyz'); - const extractor = getBufferExtractor('will-use-mocked-key', 'a.b.c'); + const extractor = getBufferExtractor('.xyz'); expect(extractor).toBe(untarBuffer); }); }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts index 22f1b670b2cc4..66f28fe58599a 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -12,6 +12,7 @@ import { AssetsGroupedByServiceByType, CategoryId, CategorySummaryList, + InstallSource, KibanaAssetType, RegistryPackage, RegistrySearchResults, @@ -21,17 +22,16 @@ import { cacheGet, cacheSet, cacheDelete, - cacheHas, - getArchiveLocation, - setArchiveLocation, - deleteArchiveLocation, + getArchiveFilelist, + setArchiveFilelist, + deleteArchiveFilelist, } from './cache'; import { ArchiveEntry, untarBuffer, unzipBuffer } from './extract'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; import { appContextService } from '../..'; -import { PackageNotFoundError } from '../../../errors'; +import { PackageNotFoundError, PackageCacheError } from '../../../errors'; export { ArchiveEntry } from './extract'; @@ -132,14 +132,14 @@ export async function fetchCategories(params?: CategoriesParams): Promise true ): Promise { const paths: string[] = []; - const archiveBuffer = await getOrFetchArchiveBuffer(pkgName, pkgVersion); - const bufferExtractor = getBufferExtractor(pkgName, pkgVersion); + const { archiveBuffer, archivePath } = await fetchArchiveBuffer(pkgName, pkgVersion); + const bufferExtractor = getBufferExtractor(archivePath); await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { const { path, buffer } = entry; const { file } = pathParts(path); @@ -153,6 +153,22 @@ export async function getArchiveInfo( return paths; } +export async function loadRegistryPackage( + pkgName: string, + pkgVersion: string +): Promise<{ paths: string[]; registryPackageInfo: RegistryPackage }> { + let paths = getArchiveFilelist(pkgName, pkgVersion); + if (!paths || paths.length === 0) { + paths = await unpackRegistryPackageToCache(pkgName, pkgVersion); + setArchiveFilelist(pkgName, pkgVersion, paths); + } + + // TODO: cache this as well? + const registryPackageInfo = await fetchInfo(pkgName, pkgVersion); + + return { paths, registryPackageInfo }; +} + export function pathParts(path: string): AssetParts { let dataset; @@ -183,45 +199,39 @@ export function pathParts(path: string): AssetParts { } as AssetParts; } -export function getBufferExtractor(pkgName: string, pkgVersion: string) { - const archiveLocation = getArchiveLocation(pkgName, pkgVersion); - if (!archiveLocation) throw new Error(`no archive location for ${pkgName} ${pkgVersion}`); - const isZip = archiveLocation.endsWith('.zip'); +export function getBufferExtractor(archivePath: string) { + const isZip = archivePath.endsWith('.zip'); const bufferExtractor = isZip ? unzipBuffer : untarBuffer; return bufferExtractor; } -async function getOrFetchArchiveBuffer(pkgName: string, pkgVersion: string): Promise { - const key = getArchiveLocation(pkgName, pkgVersion); - let buffer = key && cacheGet(key); - if (!buffer) { - buffer = await fetchArchiveBuffer(pkgName, pkgVersion); - } - - if (buffer) { - return buffer; - } else { - throw new Error(`no archive buffer for ${key}`); - } -} - -export async function ensureCachedArchiveInfo(name: string, version: string) { - const pkgkey = getArchiveLocation(name, version); - if (!pkgkey || !cacheHas(pkgkey)) { - await getArchiveInfo(name, version); +export async function ensureCachedArchiveInfo( + name: string, + version: string, + installSource: InstallSource = 'registry' +) { + const paths = getArchiveFilelist(name, version); + if (!paths || paths.length === 0) { + if (installSource === 'registry') { + await loadRegistryPackage(name, version); + } else { + throw new PackageCacheError( + `Package ${name}-${version} not cached. If it was uploaded, try uninstalling and reinstalling manually.` + ); + } } } -async function fetchArchiveBuffer(pkgName: string, pkgVersion: string): Promise { +async function fetchArchiveBuffer( + pkgName: string, + pkgVersion: string +): Promise<{ archiveBuffer: Buffer; archivePath: string }> { const { download: archivePath } = await fetchInfo(pkgName, pkgVersion); const archiveUrl = `${getRegistryUrl()}${archivePath}`; - const buffer = await getResponseStream(archiveUrl).then(streamToBuffer); + const archiveBuffer = await getResponseStream(archiveUrl).then(streamToBuffer); - setArchiveLocation(pkgName, pkgVersion, archivePath); - cacheSet(archivePath, buffer); - - return buffer; + return { archiveBuffer, archivePath }; } export function getAsset(key: string) { @@ -250,16 +260,14 @@ export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByTy }; } -export const deletePackageCache = (name: string, version: string, paths: string[]) => { - const archiveLocation = getArchiveLocation(name, version); - if (archiveLocation) { - // delete cached archive - cacheDelete(archiveLocation); +export const deletePackageCache = (name: string, version: string) => { + // get cached archive filelist + const paths = getArchiveFilelist(name, version); - // delete cached archive location - deleteArchiveLocation(name, version); - } - // delete cached archive contents - // this has been populated in Registry.getArchiveInfo() - paths.forEach((path) => cacheDelete(path)); + // delete cached archive filelist + deleteArchiveFilelist(name, version); + + // delete cached archive files + // this has been populated in unpackRegistryPackageToCache() + paths?.forEach((path) => cacheDelete(path)); }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts index 6618220a27085..ff9a7871a7db8 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts @@ -28,16 +28,19 @@ const getDefaultRegistryUrl = (): string => { } }; +// Custom registry URL is currently only for internal Elastic development and is unsupported export const getRegistryUrl = (): string => { const customUrl = appContextService.getConfig()?.registryUrl; - const isGoldPlus = licenseService.isGoldPlus(); + const isEnterprise = licenseService.isEnterprise(); - if (customUrl && isGoldPlus) { + if (customUrl && isEnterprise) { return customUrl; } if (customUrl) { - appContextService.getLogger().warn('Gold license is required to use a custom registry url.'); + appContextService + .getLogger() + .warn('Enterprise license is required to use a custom registry url.'); } return getDefaultRegistryUrl(); diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index fc5ba1af196ad..0c070959e3b93 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -52,6 +52,7 @@ export { KibanaAssetReference, ElasticsearchAssetType, RegistryPackage, + InstallablePackage, AssetType, Installable, KibanaAssetType, @@ -68,6 +69,7 @@ export { Settings, SettingsSOAttributes, InstallType, + InstallSource, // Agent Request types PostAgentEnrollRequest, PostAgentCheckinRequest, diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index 87e9257b7189c..24ac1970cb225 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -31,6 +31,7 @@ const AgentEventBase = { schema.literal('STOPPING'), schema.literal('STOPPED'), schema.literal('DEGRADED'), + schema.literal('UPDATING'), ]), // Action results schema.literal('DATA_DUMP'), diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 3866ef095563e..4fd1f3f3e1573 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -13,6 +13,7 @@ export const GetAgentsRequestSchema = { perPage: schema.number({ defaultValue: 20 }), kuery: schema.maybe(schema.string()), showInactive: schema.boolean({ defaultValue: false }), + showUpgradeable: schema.boolean({ defaultValue: false }), }), }; @@ -58,6 +59,7 @@ export const PostAgentCheckinRequestBodyJSONSchema = { 'DEGRADED', 'DATA_DUMP', 'ACKNOWLEDGED', + 'UPDATING', 'UNKNOWN', ], }, @@ -172,20 +174,28 @@ export const PostAgentUnenrollRequestSchema = { ), }; +export const PostBulkAgentUnenrollRequestSchema = { + body: schema.object({ + agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), + force: schema.maybe(schema.boolean()), + }), +}; + export const PostAgentUpgradeRequestSchema = { params: schema.object({ agentId: schema.string(), }), body: schema.object({ - source_uri: schema.string(), + source_uri: schema.maybe(schema.string()), version: schema.string(), }), }; -export const PostBulkAgentUnenrollRequestSchema = { +export const PostBulkAgentUpgradeRequestSchema = { body: schema.object({ agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), - force: schema.maybe(schema.boolean()), + source_uri: schema.maybe(schema.string()), + version: schema.string(), }), }; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts index dc0f111680490..cdb23da5b6b11 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts @@ -10,6 +10,7 @@ export const ListWithKuerySchema = schema.object({ perPage: schema.maybe(schema.number({ defaultValue: 20 })), sortField: schema.maybe(schema.string()), sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])), + showUpgradeable: schema.maybe(schema.boolean()), kuery: schema.maybe(schema.string()), }); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts index 752ffef51b43b..dd354b4927836 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts @@ -3,38 +3,42 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { act } from 'react-dom/test-utils'; + import { TestBed } from '../../../../../test_utils'; export const getFormActions = (testBed: TestBed) => { - const { find, form } = testBed; + const { find, form, component } = testBed; // User actions - const clickSubmitButton = () => { - find('submitButton').simulate('click'); - }; + const clickSubmitButton = async () => { + await act(async () => { + find('submitButton').simulate('click'); + }); - const clickAddDocumentsButton = () => { - find('addDocumentsButton').simulate('click'); + component.update(); }; - const clickShowRequestLink = () => { - find('showRequestLink').simulate('click'); + const clickShowRequestLink = async () => { + await act(async () => { + find('showRequestLink').simulate('click'); + }); + + component.update(); }; const toggleVersionSwitch = () => { - form.toggleEuiSwitch('versionToggle'); - }; + act(() => { + form.toggleEuiSwitch('versionToggle'); + }); - const toggleOnFailureSwitch = () => { - form.toggleEuiSwitch('onFailureToggle'); + component.update(); }; return { clickSubmitButton, clickShowRequestLink, toggleVersionSwitch, - toggleOnFailureSwitch, - clickAddDocumentsButton, }; }; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts index 43ca849e61aee..6c446e8254f6b 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts @@ -11,7 +11,6 @@ import { TestBed, TestBedConfig, findTestSubject, - nextTick, } from '../../../../../test_utils'; import { PipelinesList } from '../../../public/application/sections/pipelines_list'; import { WithAppDependencies } from './setup_environment'; @@ -32,13 +31,17 @@ export type PipelineListTestBed = TestBed & { }; const createActions = (testBed: TestBed) => { - const { find } = testBed; - /** * User Actions */ - const clickReloadButton = () => { - find('reloadButton').simulate('click'); + const clickReloadButton = async () => { + const { component, find } = testBed; + + await act(async () => { + find('reloadButton').simulate('click'); + }); + + component.update(); }; const clickPipelineAt = async (index: number) => { @@ -49,16 +52,19 @@ const createActions = (testBed: TestBed) => { await act(async () => { const { href } = pipelineLink.props(); router.navigateTo(href!); - await nextTick(); - component.update(); }); + component.update(); }; const clickActionMenu = (pipelineName: string) => { const { component } = testBed; - // When a table has > 2 actions, EUI displays an overflow menu with an id "-actions" - component.find(`div[id="${pipelineName}-actions"] button`).simulate('click'); + act(() => { + // When a table has > 2 actions, EUI displays an overflow menu with an id "-actions" + component.find(`div[id="${pipelineName}-actions"] button`).simulate('click'); + }); + + component.update(); }; const clickPipelineAction = (pipelineName: string, action: 'edit' | 'clone' | 'delete') => { @@ -67,7 +73,11 @@ const createActions = (testBed: TestBed) => { clickActionMenu(pipelineName); - component.find('.euiContextMenuItem').at(actions.indexOf(action)).simulate('click'); + act(() => { + component.find('.euiContextMenuItem').at(actions.indexOf(action)).simulate('click'); + }); + + component.update(); }; return { diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index d9a0ac4115389..eff2572aea38d 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -4,20 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import axios from 'axios'; +import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { LocationDescriptorObject } from 'history'; +import { HttpSetup } from 'kibana/public'; + import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { notificationServiceMock, - fatalErrorsServiceMock, docLinksServiceMock, - injectedMetadataServiceMock, scopedHistoryMock, } from '../../../../../../src/core/public/mocks'; import { usageCollectionPluginMock } from '../../../../../../src/plugins/usage_collection/public/mocks'; -import { HttpService } from '../../../../../../src/core/public/http'; - import { breadcrumbService, documentationService, @@ -27,10 +27,7 @@ import { import { init as initHttpRequests } from './http_requests'; -const httpServiceSetupMock = new HttpService().setup({ - injectedMetadata: injectedMetadataServiceMock.createSetupContract(), - fatalErrors: fatalErrorsServiceMock.createSetupContract(), -}); +const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const history = scopedHistoryMock.create(); history.createHref.mockImplementation((location: LocationDescriptorObject) => { @@ -53,7 +50,7 @@ const appServices = { export const setupEnvironment = () => { uiMetricService.setup(usageCollectionPluginMock.createSetupContract()); - apiService.setup(httpServiceSetupMock, uiMetricService); + apiService.setup((mockHttpClient as unknown) as HttpSetup, uiMetricService); documentationService.setup(docLinksServiceMock.createStartContract()); breadcrumbService.setup(() => {}); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx index f8e0030441ba0..6e0889ac55d4d 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx @@ -28,8 +28,7 @@ jest.mock('@elastic/eui', () => { }; }); -// FLAKY: https://github.com/elastic/kibana/issues/66856 -describe.skip('', () => { +describe('', () => { let testBed: PipelinesCloneTestBed; const { server, httpRequestsMockHelpers } = setupEnvironment(); @@ -38,13 +37,14 @@ describe.skip('', () => { server.restore(); }); - beforeEach(async () => { - httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_CLONE); + httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_CLONE); + beforeEach(async () => { await act(async () => { testBed = await setup(); - await testBed.waitFor('pipelineForm'); }); + + testBed.component.update(); }); test('should render the correct page header', () => { @@ -61,12 +61,9 @@ describe.skip('', () => { describe('form submission', () => { it('should send the correct payload', async () => { - const { actions, waitFor } = testBed; + const { actions } = testBed; - await act(async () => { - actions.clickSubmitButton(); - await waitFor('pipelineForm', 0); - }); + await actions.clickSubmitButton(); const latestRequest = server.requests[server.requests.length - 1]; @@ -75,7 +72,7 @@ describe.skip('', () => { name: `${PIPELINE_TO_CLONE.name}-copy`, }; - expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); }); }); }); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx index 18ca71f2bb73a..976627b1fa8a8 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { setupEnvironment, pageHelpers } from './helpers'; import { PipelinesCreateTestBed } from './helpers/pipelines_create.helpers'; import { nestedProcessorsErrorFixture } from './fixtures'; @@ -43,8 +43,9 @@ describe('', () => { beforeEach(async () => { await act(async () => { testBed = await setup(); - await testBed.waitFor('pipelineForm'); }); + + testBed.component.update(); }); test('should render the correct page header', () => { @@ -60,28 +61,20 @@ describe('', () => { }); test('should toggle the version field', async () => { - const { actions, component, exists } = testBed; + const { actions, exists } = testBed; // Version field should be hidden by default expect(exists('versionField')).toBe(false); - await act(async () => { - actions.toggleVersionSwitch(); - await nextTick(); - component.update(); - }); + actions.toggleVersionSwitch(); expect(exists('versionField')).toBe(true); }); test('should show the request flyout', async () => { - const { actions, component, find, exists } = testBed; + const { actions, find, exists } = testBed; - await act(async () => { - actions.clickShowRequestLink(); - await nextTick(); - component.update(); - }); + await actions.clickShowRequestLink(); // Verify request flyout opens expect(exists('requestFlyout')).toBe(true); @@ -92,23 +85,18 @@ describe('', () => { test('should prevent form submission if required fields are missing', async () => { const { form, actions, component, find } = testBed; - await act(async () => { - actions.clickSubmitButton(); - await nextTick(); - component.update(); - }); + await actions.clickSubmitButton(); expect(form.getErrorsMessages()).toEqual(['Name is required.']); expect(find('submitButton').props().disabled).toEqual(true); - // Add required fields and verify button is enabled again - form.setInputValue('nameField.input', 'my_pipeline'); - await act(async () => { - await nextTick(); - component.update(); + // Add required fields and verify button is enabled again + form.setInputValue('nameField.input', 'my_pipeline'); }); + component.update(); + expect(find('submitButton').props().disabled).toEqual(false); }); }); @@ -117,23 +105,27 @@ describe('', () => { beforeEach(async () => { await act(async () => { testBed = await setup(); + }); - const { waitFor, form } = testBed; + testBed.component.update(); + + await act(async () => { + testBed.form.setInputValue('nameField.input', 'my_pipeline'); + }); - await waitFor('pipelineForm'); + testBed.component.update(); - form.setInputValue('nameField.input', 'my_pipeline'); - form.setInputValue('descriptionField.input', 'pipeline description'); + await act(async () => { + testBed.form.setInputValue('descriptionField.input', 'pipeline description'); }); + + testBed.component.update(); }); test('should send the correct payload', async () => { - const { actions, waitFor } = testBed; + const { actions } = testBed; - await act(async () => { - actions.clickSubmitButton(); - await waitFor('pipelineForm', 0); - }); + await actions.clickSubmitButton(); const latestRequest = server.requests[server.requests.length - 1]; @@ -143,11 +135,11 @@ describe('', () => { processors: [], }; - expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); }); test('should surface API errors from the request', async () => { - const { actions, find, exists, waitFor } = testBed; + const { actions, find, exists } = testBed; const error = { status: 409, @@ -157,29 +149,29 @@ describe('', () => { httpRequestsMockHelpers.setCreatePipelineResponse(undefined, { body: error }); - await act(async () => { - actions.clickSubmitButton(); - await waitFor('savePipelineError'); - }); + await actions.clickSubmitButton(); expect(exists('savePipelineError')).toBe(true); expect(find('savePipelineError').text()).toContain(error.message); }); test('displays nested pipeline errors as a flat list', async () => { - const { actions, find, exists, waitFor } = testBed; + const { actions, find, exists, component } = testBed; httpRequestsMockHelpers.setCreatePipelineResponse(undefined, { body: nestedProcessorsErrorFixture, }); - await act(async () => { - actions.clickSubmitButton(); - await waitFor('savePipelineError'); - }); + await actions.clickSubmitButton(); expect(exists('savePipelineError')).toBe(true); expect(exists('savePipelineError.showErrorsButton')).toBe(true); - find('savePipelineError.showErrorsButton').simulate('click'); + + await act(async () => { + find('savePipelineError.showErrorsButton').simulate('click'); + }); + + component.update(); + expect(exists('savePipelineError.hideErrorsButton')).toBe(true); expect(exists('savePipelineError.showErrorsButton')).toBe(false); expect(find('savePipelineError').find('li').length).toBe(8); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx index 6c89216e34733..3fe7f5ec42710 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx @@ -37,13 +37,14 @@ describe('', () => { server.restore(); }); - beforeEach(async () => { - httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_EDIT); + httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_EDIT); + beforeEach(async () => { await act(async () => { testBed = await setup(); - await testBed.waitFor('pipelineForm'); }); + + testBed.component.update(); }); test('should render the correct page header', () => { @@ -68,15 +69,12 @@ describe('', () => { describe('form submission', () => { it('should send the correct payload with changed values', async () => { const UPDATED_DESCRIPTION = 'updated pipeline description'; - const { actions, form, waitFor } = testBed; + const { actions, form } = testBed; // Make change to description field form.setInputValue('descriptionField.input', UPDATED_DESCRIPTION); - await act(async () => { - actions.clickSubmitButton(); - await waitFor('pipelineForm', 0); - }); + await actions.clickSubmitButton(); const latestRequest = server.requests[server.requests.length - 1]; @@ -87,7 +85,7 @@ describe('', () => { description: UPDATED_DESCRIPTION, }; - expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); }); }); }); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts index c0acc39ca35a1..d29d38da80e47 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts @@ -8,7 +8,7 @@ import { act } from 'react-dom/test-utils'; import { API_BASE_PATH } from '../../common/constants'; -import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { setupEnvironment, pageHelpers } from './helpers'; import { PipelineListTestBed } from './helpers/pipelines_list.helpers'; const { setup } = pageHelpers.pipelinesList; @@ -22,6 +22,14 @@ describe('', () => { }); describe('With pipelines', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + }); + + testBed.component.update(); + }); + const pipeline1 = { name: 'test_pipeline1', description: 'test_pipeline1 description', @@ -38,16 +46,6 @@ describe('', () => { httpRequestsMockHelpers.setLoadPipelinesResponse(pipelines); - beforeEach(async () => { - testBed = await setup(); - - await act(async () => { - const { waitFor } = testBed; - - await waitFor('pipelinesTable'); - }); - }); - test('should render the list view', async () => { const { exists, find, table } = testBed; @@ -72,14 +70,10 @@ describe('', () => { }); test('should reload the pipeline data', async () => { - const { component, actions } = testBed; + const { actions } = testBed; const totalRequests = server.requests.length; - await act(async () => { - actions.clickReloadButton(); - await nextTick(100); - component.update(); - }); + await actions.clickReloadButton(); expect(server.requests.length).toBe(totalRequests + 1); expect(server.requests[server.requests.length - 1].url).toBe(API_BASE_PATH); @@ -118,33 +112,27 @@ describe('', () => { await act(async () => { confirmButton!.click(); - await nextTick(); - component.update(); }); - const latestRequest = server.requests[server.requests.length - 1]; + component.update(); + + const deleteRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.method).toBe('DELETE'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/${pipelineName}`); - expect(latestRequest.status).toEqual(200); + expect(deleteRequest.method).toBe('DELETE'); + expect(deleteRequest.url).toBe(`${API_BASE_PATH}/${pipelineName}`); + expect(deleteRequest.status).toEqual(200); }); }); describe('No pipelines', () => { - beforeEach(async () => { + test('should display an empty prompt', async () => { httpRequestsMockHelpers.setLoadPipelinesResponse([]); - testBed = await setup(); - await act(async () => { - const { waitFor } = testBed; - - await waitFor('emptyList'); + testBed = await setup(); }); - }); - - test('should display an empty prompt', async () => { - const { exists, find } = testBed; + const { exists, component, find } = testBed; + component.update(); expect(exists('sectionLoading')).toBe(false); expect(exists('emptyList')).toBe(true); @@ -162,13 +150,11 @@ describe('', () => { httpRequestsMockHelpers.setLoadPipelinesResponse(undefined, { body: error }); - testBed = await setup(); - await act(async () => { - const { waitFor } = testBed; - - await waitFor('pipelineLoadError'); + testBed = await setup(); }); + + testBed.component.update(); }); test('should render an error message if error fetching pipelines', async () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx index 10fb73df1ce1c..72c25d6dff72d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx @@ -6,19 +6,9 @@ import { act } from 'react-dom/test-utils'; import React from 'react'; -import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; - -import { LocationDescriptorObject } from 'history'; -import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; import { registerTestBed, TestBed } from '../../../../../../../test_utils'; -import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; - -import { - breadcrumbService, - uiMetricService, - documentationService, - apiService, -} from '../../../services'; +import { Props } from '../'; +import { ProcessorsEditorWithDeps } from './processors_editor'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -67,28 +57,8 @@ jest.mock('react-virtualized', () => { }; }); -const history = scopedHistoryMock.create(); -history.createHref.mockImplementation((location: LocationDescriptorObject) => { - return `${location.pathname}?${location.search}`; -}); - -const appServices = { - breadcrumbs: breadcrumbService, - metric: uiMetricService, - documentation: documentationService, - api: apiService, - notifications: notificationServiceMock.createSetupContract(), - history, -}; - const testBedSetup = registerTestBed( - (props: Props) => ( - - - - - - ), + (props: Props) => , { doMountAsync: false, } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx new file mode 100644 index 0000000000000..8fb51ade921a9 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; + +import { LocationDescriptorObject } from 'history'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; +import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; + +import { + breadcrumbService, + uiMetricService, + documentationService, + apiService, +} from '../../../services'; + +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { + return `${location.pathname}?${location.search}`; +}); + +const appServices = { + breadcrumbs: breadcrumbService, + metric: uiMetricService, + documentation: documentationService, + api: apiService, + notifications: notificationServiceMock.createSetupContract(), + history, +}; + +export const ProcessorsEditorWithDeps: React.FunctionComponent = (props) => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx index 222e0a491e0d2..570d9878f7634 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx @@ -8,26 +8,15 @@ import React from 'react'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; - -import { LocationDescriptorObject } from 'history'; -import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; /* eslint-disable @kbn/eslint/no-restricted-paths */ import { usageCollectionPluginMock } from 'src/plugins/usage_collection/public/mocks'; import { registerTestBed, TestBed } from '../../../../../../../test_utils'; import { stubWebWorker } from '../../../../../../../test_utils/stub_web_worker'; - -import { - breadcrumbService, - uiMetricService, - documentationService, - apiService, -} from '../../../services'; - -import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; - +import { uiMetricService, apiService } from '../../../services'; +import { Props } from '../'; import { initHttpRequests } from './http_requests.helpers'; +import { ProcessorsEditorWithDeps } from './processors_editor'; stubWebWorker(); @@ -75,34 +64,8 @@ jest.mock('react-virtualized', () => { }; }); -const history = scopedHistoryMock.create(); -history.createHref.mockImplementation((location: LocationDescriptorObject) => { - return `${location.pathname}?${location.search}`; -}); - -const appServices = { - breadcrumbs: breadcrumbService, - metric: uiMetricService, - documentation: documentationService, - api: apiService, - notifications: notificationServiceMock.createSetupContract(), - history, - uiSettings: {}, - urlGenerators: { - getUrlGenerator: jest.fn().mockReturnValue({ - createUrl: jest.fn(), - }), - }, -}; - const testBedSetup = registerTestBed( - (props: Props) => ( - - - - - - ), + (props: Props) => , { doMountAsync: false, } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx index e91974adca20a..a9e8028fd02ee 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx @@ -4,26 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ import classNames from 'classnames'; -import React, { FunctionComponent, useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, memo } from 'react'; import { EuiFieldText, EuiText, keys } from '@elastic/eui'; export interface Props { placeholder: string; ariaLabel: string; onChange: (value: string) => void; - disabled: boolean; + /** + * Whether the containing element of the text input can be focused. + * + * If it cannot be focused, this component cannot switch to showing + * the text input field. + * + * Defaults to false. + */ + disabled?: boolean; text?: string; } -export const InlineTextInput: FunctionComponent = ({ - disabled, +function _InlineTextInput({ placeholder, text, ariaLabel, + disabled = false, onChange, -}) => { +}: Props): React.ReactElement | null { const [isShowingTextInput, setIsShowingTextInput] = useState(false); - const [textValue, setTextValue] = useState(text ?? ''); + const [textValue, setTextValue] = useState(() => text ?? ''); const containerClasses = classNames('pipelineProcessorsEditor__item__textContainer', { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -39,6 +47,10 @@ export const InlineTextInput: FunctionComponent = ({ }); }, [setIsShowingTextInput, onChange, textValue]); + useEffect(() => { + setTextValue(text ?? ''); + }, [text]); + useEffect(() => { const keyboardListener = (event: KeyboardEvent) => { if (event.key === keys.ESCAPE || event.code === 'Escape') { @@ -71,7 +83,11 @@ export const InlineTextInput: FunctionComponent = ({ /> ) : ( -
setIsShowingTextInput(true)}> +
setIsShowingTextInput(true)} + >
{text || {placeholder}} @@ -79,4 +95,6 @@ export const InlineTextInput: FunctionComponent = ({
); -}; +} + +export const InlineTextInput = memo(_InlineTextInput); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx index bf69f817183ab..dd7798a37dd4e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -5,7 +5,7 @@ */ import classNames from 'classnames'; -import React, { FunctionComponent, memo } from 'react'; +import React, { FunctionComponent, memo, useCallback } from 'react'; import { EuiButtonToggle, EuiFlexGroup, @@ -89,6 +89,32 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( 'pipelineProcessorsEditor__item--displayNone': isInMoveMode && !processor.options.description, }); + const onDescriptionChange = useCallback( + (nextDescription) => { + let nextOptions: Record; + if (!nextDescription) { + const { description: _description, ...restOptions } = processor.options; + nextOptions = restOptions; + } else { + nextOptions = { + ...processor.options, + description: nextDescription, + }; + } + processorsDispatch({ + type: 'updateProcessor', + payload: { + processor: { + ...processor, + options: nextOptions, + }, + selector, + }, + }); + }, + [processor, processorsDispatch, selector] + ); + const renderMoveButton = () => { const label = !isMovingThisProcessor ? i18nTexts.moveButtonLabel @@ -159,6 +185,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( color={isDimmed ? 'subdued' : undefined} > { editor.setMode({ @@ -175,28 +202,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( { - let nextOptions: Record; - if (!nextDescription) { - const { description: _description, ...restOptions } = processor.options; - nextOptions = restOptions; - } else { - nextOptions = { - ...processor.options, - description: nextDescription, - }; - } - processorsDispatch({ - type: 'updateProcessor', - payload: { - processor: { - ...processor, - options: nextOptions, - }, - selector, - }, - }); - }} + onChange={onDescriptionChange} ariaLabel={i18nTexts.processorTypeLabel({ type: processor.type })} text={description} placeholder={i18nTexts.descriptionPlaceholder} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx index b663daedd9b9c..f663832702b1c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx @@ -24,10 +24,8 @@ import { getProcessorDescriptor } from '../shared'; import { DocumentationButton } from './documentation_button'; import { ProcessorSettingsFields } from './processor_settings_fields'; +import { Fields } from './processor_form.container'; -interface Fields { - fields: { [key: string]: any }; -} export interface Props { isOnFailure: boolean; form: FormHook; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx index 25c9579e3c48e..61a6f985340ea 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx @@ -19,6 +19,7 @@ export type OnSubmitHandler = (processor: ProcessorFormOnSubmitArg) => void; export type OnFormUpdateHandler = (form: OnFormUpdateArg) => void; export interface Fields { + type: string; fields: { [key: string]: any }; } @@ -57,8 +58,28 @@ export const ProcessorFormContainer: FunctionComponent = ({ return { ...processor, options } as ProcessorInternal; }, [processor, unsavedFormState]); + const formSerializer = useCallback( + (formState) => { + return { + type: formState.type, + fields: formState.customOptions + ? { + ...formState.customOptions, + } + : { + ...formState.fields, + // The description field is not editable in processor forms currently. We re-add it here or it will be + // stripped. + description: processor ? processor.options.description : undefined, + }, + }; + }, + [processor] + ); + const { form } = useForm({ defaultValue: { fields: getProcessor().options }, + serializer: formSerializer, }); const { subscribe } = form; @@ -67,8 +88,7 @@ export const ProcessorFormContainer: FunctionComponent = ({ const { isValid, data } = await form.submit(); if (isValid) { - const { type, customOptions, fields } = data as FormData; - const options = customOptions ? customOptions : fields; + const { type, fields: options } = data as FormData; unsavedFormState.current = options; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx index 1777cac2a5615..e66534ae1b250 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx @@ -18,7 +18,7 @@ import { import { TextEditor } from '../../field_components'; import { to, from, EDITOR_PX_HEIGHT } from '../shared'; -const ignoreFailureConfig: FieldConfig = { +const ignoreFailureConfig: FieldConfig = { defaultValue: false, deserializer: to.booleanOrUndef, serializer: from.undefinedIfValue(false), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx index 5b3df63a11294..eb792f5a85213 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx @@ -47,7 +47,7 @@ interface Props { const { emptyField } = fieldValidators; -const typeConfig: FieldConfig = { +const typeConfig: FieldConfig = { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.typeField.typeFieldLabel', { defaultMessage: 'Processor', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx index f49e77501f931..4a8cfc8be2d8c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx @@ -20,7 +20,7 @@ import { XJsonEditor } from '../field_components'; import { Fields } from '../processor_form.container'; import { EDITOR_PX_HEIGHT } from './shared'; -const customConfig: FieldConfig = { +const customConfig: FieldConfig = { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldLabel', { defaultMessage: 'Configuration', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts index e45469e23e8a0..c33cce323b727 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts @@ -74,6 +74,6 @@ export const EDITOR_PX_HEIGHT = { large: 300, }; -export type FieldsConfig = Record; +export type FieldsConfig = Record>; export type FormFieldsComponent = FunctionComponent<{ initialFieldValues?: Record }>; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx index 57ecb6f7f1187..cd32e2ec54726 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx @@ -7,11 +7,15 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; import classNames from 'classnames'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; export interface Props { isVisible: boolean; isDisabled: boolean; + /** + * Useful for buttons at the very top or bottom of lists to avoid any overflow. + */ + compressed?: boolean; onClick: (event: React.MouseEvent) => void; 'data-test-subj'?: string; } @@ -29,7 +33,7 @@ const cannotMoveHereLabel = i18n.translate( ); export const DropZoneButton: FunctionComponent = (props) => { - const { onClick, isDisabled, isVisible } = props; + const { onClick, isDisabled, isVisible, compressed } = props; const isUnavailable = isVisible && isDisabled; const containerClasses = classNames({ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -40,14 +44,16 @@ export const DropZoneButton: FunctionComponent = (props) => { const buttonClasses = classNames({ // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__tree__dropZoneButton--visible': isVisible, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'pipelineProcessorsEditor__tree__dropZoneButton--compressed': compressed, }); - const content = ( + return (
{} : onClick} @@ -55,15 +61,4 @@ export const DropZoneButton: FunctionComponent = (props) => { />
); - - return isUnavailable ? ( - - {content} - - ) : ( - content - ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx index 89407fd4366d8..cbff02070483a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent, MutableRefObject, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { FunctionComponent, MutableRefObject, useEffect, useMemo } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; import { AutoSizer, List, WindowScroller } from 'react-virtualized'; import { DropSpecialLocations } from '../../../constants'; @@ -65,6 +65,10 @@ export const PrivateTree: FunctionComponent = ({ windowScrollerRef, listRef, }) => { + const selectors: string[][] = useMemo(() => { + return processors.map((_, idx) => selector.concat(String(idx))); + }, [processors, selector]); + const renderRow = ({ idx, info, @@ -78,50 +82,45 @@ export const PrivateTree: FunctionComponent = ({ return ( <> {idx === 0 ? ( - - { - event.preventDefault(); - onAction({ - type: 'move', - payload: { - destination: selector.concat(DropSpecialLocations.top), - source: movingProcessor!.selector, - }, - }); - }} - isVisible={Boolean(movingProcessor)} - isDisabled={!movingProcessor || isDropZoneAboveDisabled(info, movingProcessor)} - /> - - ) : undefined} - - - - { event.preventDefault(); onAction({ type: 'move', payload: { - destination: selector.concat(String(idx + 1)), + destination: selector.concat(DropSpecialLocations.top), source: movingProcessor!.selector, }, }); }} + isVisible={Boolean(movingProcessor)} + isDisabled={!movingProcessor || isDropZoneAboveDisabled(info, movingProcessor)} /> - + ) : undefined} + + { + event.preventDefault(); + onAction({ + type: 'move', + payload: { + destination: selector.concat(String(idx + 1)), + source: movingProcessor!.selector, + }, + }); + }} + /> ); }; @@ -141,52 +140,50 @@ export const PrivateTree: FunctionComponent = ({ {({ height, registerChild, isScrolling, onChildScroll, scrollTop }: any) => { return ( - - - {({ width }) => { - return ( -
- { - const processor = processors[index]; - return calculateItemHeight({ - processor, - isFirstInArray: index === 0, - }); - }} - rowRenderer={({ index: idx, style }) => { - const processor = processors[idx]; - const above = processors[idx - 1]; - const below = processors[idx + 1]; - const info: ProcessorInfo = { - id: processor.id, - selector: selector.concat(String(idx)), - aboveId: above?.id, - belowId: below?.id, - }; + + {({ width }) => { + return ( +
+ { + const processor = processors[index]; + return calculateItemHeight({ + processor, + isFirstInArray: index === 0, + }); + }} + rowRenderer={({ index: idx, style }) => { + const processor = processors[idx]; + const above = processors[idx - 1]; + const below = processors[idx + 1]; + const info: ProcessorInfo = { + id: processor.id, + selector: selectors[idx], + aboveId: above?.id, + belowId: below?.id, + }; - return ( -
- {renderRow({ processor, info, idx })} -
- ); - }} - processors={processors} - /> -
- ); - }} -
- + return ( +
+ {renderRow({ processor, info, idx })} +
+ ); + }} + processors={processors} + /> +
+ ); + }} +
); }}
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss index 25e4eb7320bf4..f1e399428cdf2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss @@ -31,15 +31,14 @@ } } $dropZoneButtonHeight: 60px; - $dropZoneButtonOffsetY: $dropZoneButtonHeight * -0.5; + $dropZoneButtonOffsetY: $dropZoneButtonHeight * 0.5; &__dropZoneButton { position: absolute; padding: 0; height: $dropZoneButtonHeight; - margin-top: $dropZoneButtonOffsetY; + margin-top: -$dropZoneButtonOffsetY; width: 100%; - opacity: 0; text-decoration: none !important; z-index: $dropZoneZIndex; @@ -49,6 +48,10 @@ transform: none !important; } } + + &--compressed { + height: $dropZoneButtonOffsetY; + } } &__addProcessorButton { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx index ffc0a1459b791..46d237e1467e7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx @@ -98,9 +98,16 @@ export const ProcessorsTree: FunctionComponent = memo((props) => { />
- - - {!processors.length && ( + + {!processors.length && ( + // We want to make this dropzone the max length of its container + = memo((props) => { }); }} /> - )} + + )} + { onAction({ type: 'addProcessor', payload: { target: baseSelector } }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx index e7ccb9d17f2b1..7a32e6328bc73 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx @@ -28,6 +28,10 @@ export interface TestPipelineConfig { verbose?: boolean; } +export interface TestPipelineFlyoutForm { + documents: string | Document[]; +} + export const TestPipelineFlyout: React.FunctionComponent = ({ onClose, activeTab, @@ -46,7 +50,7 @@ export const TestPipelineFlyout: React.FunctionComponent = ({ config: { documents: cachedDocuments, verbose: cachedVerbose }, } = testPipelineData; - const { form } = useForm({ + const { form } = useForm({ defaultValue: { documents: cachedDocuments || '', }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx index 51b75dab170a3..aa9e2879aaddf 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx @@ -20,6 +20,7 @@ import { FormHook } from '../../../../../shared_imports'; import { Document } from '../../types'; import { Tabs, TestPipelineFlyoutTab, OutputTab, DocumentsTab } from './test_pipeline_tabs'; +import { TestPipelineFlyoutForm } from './test_pipeline_flyout.container'; export interface Props { onClose: () => void; handleTestPipeline: ( @@ -30,9 +31,7 @@ export interface Props { cachedVerbose?: boolean; cachedDocuments?: Document[]; testOutput?: any; - form: FormHook<{ - documents: string | Document[]; - }>; + form: FormHook; validateAndTestPipeline: () => Promise; selectedTab: TestPipelineFlyoutTab; setSelectedTab: (selectedTa: TestPipelineFlyoutTab) => void; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx index ae784472ebbd9..cd82e0f4ff5ca 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx @@ -75,7 +75,7 @@ const i18nTexts = { ), }; -const documentFieldConfig: FieldConfig = { +const documentFieldConfig: FieldConfig = { label: i18n.translate( 'xpack.ingestPipelines.testPipelineFlyout.documentsForm.documentsFieldLabel', { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss index f200e25453a2a..bd2789cf645c7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss @@ -13,17 +13,7 @@ animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance; } -.lnsDimensionContainer--noAnimation { - animation: none; -} - .lnsDimensionContainer__footer, .lnsDimensionContainer__header { padding: $euiSizeS; } - -.lnsDimensionContainer__trigger { - width: 100%; - display: block; - word-break: break-word; -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index 19f4c0428260e..8f1b441d1d285 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -16,89 +16,42 @@ import { EuiOutsideClickDetector, } from '@elastic/eui'; -import classNames from 'classnames'; import { i18n } from '@kbn/i18n'; -import { VisualizationDimensionGroupConfig } from '../../../types'; -import { DimensionContainerState } from './types'; export function DimensionContainer({ - dimensionContainerState, - setDimensionContainerState, - groups, - accessor, - groupId, - trigger, + isOpen, + groupLabel, + handleClose, panel, - panelTitle, }: { - dimensionContainerState: DimensionContainerState; - setDimensionContainerState: (newState: DimensionContainerState) => void; - groups: VisualizationDimensionGroupConfig[]; - accessor: string; - groupId: string; - trigger: React.ReactElement; + isOpen: boolean; + handleClose: () => void; panel: React.ReactElement; - panelTitle: React.ReactNode; + groupLabel: string; }) { - const [openByCreation, setIsOpenByCreation] = useState( - dimensionContainerState.openId === accessor - ); const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); - const [flyoutIsVisible, setFlyoutIsVisible] = useState(false); - - const noMatch = dimensionContainerState.isOpen - ? !groups.some((d) => d.accessors.includes(accessor)) - : false; const closeFlyout = () => { - setDimensionContainerState({ - isOpen: false, - openId: null, - addingToGroupId: null, - }); - setIsOpenByCreation(false); + handleClose(); setFocusTrapIsEnabled(false); - setFlyoutIsVisible(false); - }; - - const openFlyout = () => { - setFlyoutIsVisible(true); - setTimeout(() => { - setFocusTrapIsEnabled(true); - }, 255); }; - const flyoutShouldBeOpen = - dimensionContainerState.isOpen && - (dimensionContainerState.openId === accessor || - (noMatch && dimensionContainerState.addingToGroupId === groupId)); - useEffect(() => { - if (flyoutShouldBeOpen) { - openFlyout(); + if (isOpen) { + // without setTimeout here the flyout pushes content when animating + setTimeout(() => { + setFocusTrapIsEnabled(true); + }, 255); } - }); + }, [isOpen]); - useEffect(() => { - if (!flyoutShouldBeOpen) { - if (flyoutIsVisible) { - setFlyoutIsVisible(false); - } - if (focusTrapIsEnabled) { - setFocusTrapIsEnabled(false); - } - } - }, [flyoutShouldBeOpen, flyoutIsVisible, focusTrapIsEnabled]); - - const flyout = flyoutIsVisible && ( + return isOpen ? ( - +
@@ -109,7 +62,14 @@ export function DimensionContainer({ iconType="sortLeft" flush="left" > - {panelTitle} + + {i18n.translate('xpack.lens.configure.configurePanelTitle', { + defaultMessage: '{groupLabel} configuration', + values: { + groupLabel, + }, + })} + @@ -126,12 +86,5 @@ export function DimensionContainer({
- ); - - return ( - <> - {trigger} - {flyout} - - ); + ) : null; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index a9e2d6dc696ab..44dc22d20a4fe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -14,7 +14,6 @@ import { } from '../../mocks'; import { ChildDragDropProvider } from '../../../drag_drop'; import { EuiFormRow } from '@elastic/eui'; -import { mount } from 'enzyme'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; @@ -211,7 +210,7 @@ describe('LayerPanel', () => { groupId: 'a', accessors: ['newid'], filterOperations: () => true, - supportsMoreColumns: false, + supportsMoreColumns: true, dataTestSubj: 'lnsGroup', enableDimensionEditor: true, }, @@ -220,11 +219,14 @@ describe('LayerPanel', () => { mockVisualization.renderDimensionEditor = jest.fn(); const component = mountWithIntl(); + act(() => { + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + }); + component.update(); - const group = component.find('DimensionContainer'); - const panel = mount(group.prop('panel')); - - expect(panel.children()).toHaveLength(2); + const group = component.find('DimensionContainer').first(); + const panel: React.ReactElement = group.prop('panel'); + expect(panel.props.children).toHaveLength(2); }); it('should keep the DimensionContainer open when configuring a new dimension', () => { @@ -263,11 +265,8 @@ describe('LayerPanel', () => { }); const component = mountWithIntl(); - - const group = component.find('DimensionContainer'); - const triggerButton = mountWithIntl(group.prop('trigger')); act(() => { - triggerButton.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); }); component.update(); @@ -312,10 +311,8 @@ describe('LayerPanel', () => { const component = mountWithIntl(); - const group = component.find('DimensionContainer'); - const triggerButton = mountWithIntl(group.prop('trigger')); act(() => { - triggerButton.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); }); component.update(); expect(component.find('EuiFlyoutHeader').exists()).toBe(true); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index ce2955da890d7..e72bf75b010c3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -23,13 +23,11 @@ import { DragContext, DragDrop, ChildDragDropProvider } from '../../../drag_drop import { LayerSettings } from './layer_settings'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; -import { ConfigPanelWrapperProps, DimensionContainerState } from './types'; +import { ConfigPanelWrapperProps, ActiveDimensionState } from './types'; import { DimensionContainer } from './dimension_container'; -const initialDimensionContainerState = { - isOpen: false, - openId: null, - addingToGroupId: null, +const initialActiveDimensionState = { + isNew: false, }; function isConfiguration( @@ -70,15 +68,15 @@ export function LayerPanel( } ) { const dragDropContext = useContext(DragContext); - const [dimensionContainerState, setDimensionContainerState] = useState( - initialDimensionContainerState + const [activeDimension, setActiveDimension] = useState( + initialActiveDimensionState ); const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer, dataTestSubj } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; useEffect(() => { - setDimensionContainerState(initialDimensionContainerState); + setActiveDimension(initialActiveDimensionState); }, [props.activeVisualizationId]); if ( @@ -117,7 +115,7 @@ export function LayerPanel( const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps); const isEmptyLayer = !groups.some((d) => d.accessors.length > 0); - + const { activeId, activeGroup } = activeDimension; return ( @@ -196,31 +194,6 @@ export function LayerPanel( > <> {group.accessors.map((accessor) => { - const datasourceDimensionEditor = ( - - ); - const visDimensionEditor = - activeVisualization.renderDimensionEditor && group.enableDimensionEditor ? ( -
- -
- ) : null; return (
- { - if (dimensionContainerState.isOpen) { - setDimensionContainerState(initialDimensionContainerState); - } else { - setDimensionContainerState({ - isOpen: true, - openId: accessor, - addingToGroupId: null, // not set for existing dimension - }); - } - }, - }} - /> - } - panel={ - <> - {datasourceDimensionEditor} - {visDimensionEditor} - - } - panelTitle={i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', - values: { - groupLabel: group.groupLabel, + { + if (activeId) { + setActiveDimension(initialActiveDimensionState); + } else { + setActiveDimension({ + isNew: false, + activeGroup: group, + activeId: accessor, + }); + } }, - })} + }} /> -
- { - if (dimensionContainerState.isOpen) { - setDimensionContainerState(initialDimensionContainerState); - } else { - setDimensionContainerState({ - isOpen: true, - openId: newId, - addingToGroupId: group.groupId, - }); - } - }} - > - - - } - panelTitle={i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', - values: { - groupLabel: group.groupLabel, - }, - })} - panel={ - { - props.updateAll( - datasourceId, - newState, - activeVisualization.setDimension({ - layerId, - groupId: group.groupId, - columnId: newId, - prevState: props.visualizationState, - }) - ); - setDimensionContainerState({ - isOpen: true, - openId: newId, - addingToGroupId: null, // clear now that dimension exists - }); - }, - }} - /> - } - /> + { + if (activeId) { + setActiveDimension(initialActiveDimensionState); + } else { + setActiveDimension({ + isNew: true, + activeGroup: group, + activeId: newId, + }); + } + }} + > + +
) : null} @@ -472,6 +378,60 @@ export function LayerPanel( ); })} + setActiveDimension(initialActiveDimensionState)} + panel={ + <> + {activeGroup && activeId && ( + { + props.updateAll( + datasourceId, + newState, + activeVisualization.setDimension({ + layerId, + groupId: activeGroup.groupId, + columnId: activeId, + prevState: props.visualizationState, + }) + ); + setActiveDimension({ + ...activeDimension, + isNew: false, + }); + }, + }} + /> + )} + {activeGroup && + activeId && + !activeDimension.isNew && + activeVisualization.renderDimensionEditor && + activeGroup?.enableDimensionEditor && ( +
+ +
+ )} + + } + /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts index d42c5c3b99e53..c172c6da6848c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -10,6 +10,7 @@ import { FramePublicAPI, Datasource, DatasourceDimensionEditorProps, + VisualizationDimensionGroupConfig, } from '../../../types'; export interface ConfigPanelWrapperProps { @@ -30,8 +31,8 @@ export interface ConfigPanelWrapperProps { core: DatasourceDimensionEditorProps['core']; } -export interface DimensionContainerState { - isOpen: boolean; - openId: string | null; - addingToGroupId: string | null; +export interface ActiveDimensionState { + isNew: boolean; + activeId?: string; + activeGroup?: VisualizationDimensionGroupConfig; } diff --git a/x-pack/plugins/lists/common/constants.ts b/x-pack/plugins/lists/common/constants.ts index 1851487b824a2..d1b8e685c2c8a 100644 --- a/x-pack/plugins/lists/common/constants.ts +++ b/x-pack/plugins/lists/common/constants.ts @@ -44,10 +44,10 @@ export const ENDPOINT_LIST_ITEM_URL = '/api/endpoint_list/items'; export const ENDPOINT_LIST_ID = 'endpoint_list'; /** The name of the single global space agnostic endpoint list */ -export const ENDPOINT_LIST_NAME = 'Elastic Endpoint Security Exception List'; +export const ENDPOINT_LIST_NAME = 'Endpoint Security Exception List'; /** The description of the single global space agnostic endpoint list */ -export const ENDPOINT_LIST_DESCRIPTION = 'Elastic Endpoint Security Exception List'; +export const ENDPOINT_LIST_DESCRIPTION = 'Endpoint Security Exception List'; export const MAX_EXCEPTION_LIST_SIZE = 10000; @@ -55,7 +55,7 @@ export const MAX_EXCEPTION_LIST_SIZE = 10000; export const ENDPOINT_TRUSTED_APPS_LIST_ID = 'endpoint_trusted_apps'; /** Name of trusted apps agnostic list */ -export const ENDPOINT_TRUSTED_APPS_LIST_NAME = 'Elastic Endpoint Security Trusted Apps List'; +export const ENDPOINT_TRUSTED_APPS_LIST_NAME = 'Endpoint Security Trusted Apps List'; /** Description of trusted apps agnostic list */ -export const ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION = 'Elastic Endpoint Security Trusted Apps List'; +export const ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION = 'Endpoint Security Trusted Apps List'; diff --git a/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx new file mode 100644 index 0000000000000..cbdd253b6e2dd --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setDefaultAutoFitToBounds } from './set_default_auto_fit_to_bounds'; + +describe('setDefaultAutoFitToBounds', () => { + test('Should handle missing mapStateJSON attribute', () => { + const attributes = { + title: 'my map', + }; + expect(setDefaultAutoFitToBounds({ attributes })).toEqual({ + title: 'my map', + }); + }); + + test('Should set default auto fit to bounds when map settings exist in map state', () => { + const attributes = { + title: 'my map', + mapStateJSON: JSON.stringify({ + settings: { showSpatialFilters: false }, + }), + }; + expect(JSON.parse(setDefaultAutoFitToBounds({ attributes }).mapStateJSON!)).toEqual({ + settings: { autoFitToDataBounds: false, showSpatialFilters: false }, + }); + }); + + test('Should set default auto fit to bounds when map settings does not exist in map state', () => { + const attributes = { + title: 'my map', + mapStateJSON: JSON.stringify({}), + }; + expect(JSON.parse(setDefaultAutoFitToBounds({ attributes }).mapStateJSON!)).toEqual({ + settings: { autoFitToDataBounds: false }, + }); + }); +}); diff --git a/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts new file mode 100644 index 0000000000000..09e23b5213d6c --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MapSavedObjectAttributes } from '../map_saved_object_type'; + +export function setDefaultAutoFitToBounds({ + attributes, +}: { + attributes: MapSavedObjectAttributes; +}): MapSavedObjectAttributes { + if (!attributes || !attributes.mapStateJSON) { + return attributes; + } + + // MapState type is defined in public, no need to bring all of that to common for this migration + const mapState: { settings?: { autoFitToDataBounds: boolean } } = JSON.parse( + attributes.mapStateJSON + ); + if ('settings' in mapState) { + mapState.settings!.autoFitToDataBounds = false; + } else { + mapState.settings = { + autoFitToDataBounds: false, + }; + } + + return { + ...attributes, + mapStateJSON: JSON.stringify(mapState), + }; +} diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index bf75c86ac249d..352aed4a8cc93 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -50,6 +50,8 @@ interface Props { refreshConfig: MapRefreshConfig; renderTooltipContent?: RenderToolTipContent; triggerRefreshTimer: () => void; + title?: string; + description?: string; } interface State { @@ -197,7 +199,12 @@ export class MapContainer extends Component { if (mapInitError) { return ( -
+
{ data-dom-id={this.state.domId} data-render-complete={this.state.isInitialLoadRenderTimeoutComplete} data-shared-item + data-title={this.props.title} + data-description={this.props.description} > { type = MAP_SAVED_OBJECT_TYPE; + private _description: string; private _renderTooltipContent?: RenderToolTipContent; private _eventHandlers?: EventHandlers; private _layerList: LayerDescriptor[]; @@ -95,6 +96,7 @@ export class MapEmbeddable extends Embeddable this.onContainerStateChanged(input)); } + public getDescription() { + return this._description; + } + supportedTriggers(): Array { return [APPLY_FILTER_TRIGGER]; } @@ -238,6 +244,8 @@ export class MapEmbeddable extends Embeddable , diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts index 489a73a90cf70..b49419487b6fa 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts @@ -109,6 +109,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { { layerList, title: savedMap.title, + description: savedMap.description, editUrl: getHttp().basePath.prepend(getExistingMapPath(savedObjectId)), editApp: APP_ID, editPath: `/${MAP_PATH}/${savedObjectId}`, diff --git a/x-pack/plugins/maps/public/embeddable/types.ts b/x-pack/plugins/maps/public/embeddable/types.ts index ce06b7da9094a..8ba906111ad1e 100644 --- a/x-pack/plugins/maps/public/embeddable/types.ts +++ b/x-pack/plugins/maps/public/embeddable/types.ts @@ -6,15 +6,12 @@ import { IIndexPattern } from '../../../../../src/plugins/data/common/index_patterns'; import { MapSettings } from '../reducers/map'; -import { - EmbeddableInput, - EmbeddableOutput, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../src/plugins/embeddable/public/lib/embeddables'; +import { EmbeddableInput, EmbeddableOutput } from '../../../../../src/plugins/embeddable/public'; import { Filter, Query, RefreshInterval, TimeRange } from '../../../../../src/plugins/data/common'; import { LayerDescriptor, MapCenterAndZoom } from '../../common/descriptor_types'; export interface MapEmbeddableConfig { + description?: string; editUrl?: string; editApp?: string; editPath?: string; diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts index 896ac11e36782..5375b5ca5c59b 100644 --- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts @@ -9,7 +9,7 @@ import { MapSettings } from './map'; export function getDefaultMapSettings(): MapSettings { return { - autoFitToDataBounds: false, + autoFitToDataBounds: true, initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION, fixedLocation: { lat: 0, lon: 0, zoom: 2 }, browserLocation: { zoom: 2 }, diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.tsx b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.tsx index abc3462caf6b4..bd08b2f11fadc 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.tsx +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.tsx @@ -459,7 +459,11 @@ export class MapsAppView extends React.Component { {this._renderTopNav()}

{`screenTitle placeholder`}

- +
) : null; diff --git a/x-pack/plugins/maps/server/saved_objects/migrations.js b/x-pack/plugins/maps/server/saved_objects/migrations.js index 5db21bb110dbb..653f07772ee58 100644 --- a/x-pack/plugins/maps/server/saved_objects/migrations.js +++ b/x-pack/plugins/maps/server/saved_objects/migrations.js @@ -13,6 +13,7 @@ import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_sy import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; +import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds'; export const migrations = { map: { @@ -70,6 +71,14 @@ export const migrations = { '7.9.0': (doc) => { const attributes = removeBoundsFromSavedObject(doc); + return { + ...doc, + attributes, + }; + }, + '7.10.0': (doc) => { + const attributes = setDefaultAutoFitToBounds(doc); + return { ...doc, attributes, diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index 36b0573d609d8..a33b2e6b3e2d6 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -315,3 +315,16 @@ export const showDataGridColumnChartErrorMessageToast = ( }) ); }; + +// helper function to transform { [key]: [val] } => { [key]: val } +// for when `fields` is used in es.search since response is always an array of values +// since response always returns an array of values for each field +export const getProcessedFields = (originalObj: object) => { + const obj: { [key: string]: any } = { ...originalObj }; + for (const key of Object.keys(obj)) { + if (Array.isArray(obj[key]) && obj[key].length === 1) { + obj[key] = obj[key][0]; + } + } + return obj; +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index 633d70687dd27..cb5b6ecc18fa9 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -11,6 +11,7 @@ export { multiColumnSortFactory, showDataGridColumnChartErrorMessageToast, useRenderCellValue, + getProcessedFields, } from './common'; export { getFieldType, ChartData } from './use_column_chart'; export { useDataGrid } from './use_data_grid'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts index 361a79d42214d..667dea27de96e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts @@ -7,7 +7,7 @@ import type { SearchResponse7 } from '../../../../common/types/es_client'; import { extractErrorMessage } from '../../../../common/util/errors'; -import { EsSorting, UseDataGridReturnType } from '../../components/data_grid'; +import { EsSorting, UseDataGridReturnType, getProcessedFields } from '../../components/data_grid'; import { ml } from '../../services/ml_api_service'; import { isKeywordAndTextType } from '../common/fields'; @@ -47,9 +47,12 @@ export const getIndexData = async ( }, {} as EsSorting); const { pageIndex, pageSize } = pagination; + // TODO: remove results_field from `fields` when possible const resp: SearchResponse7 = await ml.esSearch({ index: jobConfig.dest.index, body: { + fields: ['*'], + _source: jobConfig.dest.results_field, query: searchQuery, from: pageIndex * pageSize, size: pageSize, @@ -58,8 +61,11 @@ export const getIndexData = async ( }); setRowCount(resp.hits.total.value); + const docs = resp.hits.hits.map((d) => ({ + ...getProcessedFields(d.fields), + [jobConfig.dest.results_field]: d._source[jobConfig.dest.results_field], + })); - const docs = resp.hits.hits.map((d) => d._source); setTableItems(docs); setStatus(INDEX_STATUS.LOADED); } catch (e) { 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 74d45b86c8c4d..149919d9b36c2 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 @@ -23,6 +23,7 @@ import { useRenderCellValue, EsSorting, UseIndexDataReturnType, + getProcessedFields, } from '../../../../components/data_grid'; import type { SearchResponse7 } from '../../../../../../common/types/es_client'; import { extractErrorMessage } from '../../../../../../common/util/errors'; @@ -81,6 +82,8 @@ export const useIndexData = ( query, // isDefaultQuery(query) ? matchAllQuery : query, from: pagination.pageIndex * pagination.pageSize, size: pagination.pageSize, + fields: ['*'], + _source: false, ...(Object.keys(sort).length > 0 ? { sort } : {}), }, }; @@ -88,8 +91,7 @@ export const useIndexData = ( try { const resp: IndexSearchResponse = await ml.esSearch(esSearchRequest); - const docs = resp.hits.hits.map((d) => d._source); - + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields)); setRowCount(resp.hits.total.value); setTableItems(docs); setStatus(INDEX_STATUS.LOADED); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss index 102f6630f2ee2..00463affa0d03 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss @@ -23,7 +23,7 @@ .mlDataFrameAnalyticsClassification__actualLabel { float: left; width: 80px; - padding-top: $euiSize * 4 + $euiSizeXS; + padding-top: $euiSize * 4; } /* diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx index 833b4a78178d4..f03fe2dae778c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx @@ -17,17 +17,19 @@ interface Props { } export const ClassificationExploration: FC = ({ jobId, defaultIsTraining }) => ( - +
+ +
); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 86e2c5fd2fb94..f37f649ac2595 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -6,7 +6,7 @@ import './_classification_exploration.scss'; -import React, { FC, useState, useEffect, Fragment } from 'react'; +import React, { FC, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -15,7 +15,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip, - EuiPanel, EuiSpacer, EuiText, EuiTitle, @@ -30,7 +29,6 @@ import { DataFrameAnalyticsConfig, } from '../../../../common'; import { isKeywordAndTextType } from '../../../../common/fields'; -import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/use_columns'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; import { isResultsSearchBoolQuery, @@ -39,7 +37,9 @@ import { ResultsSearchQuery, ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; -import { LoadingPanel } from '../loading_panel'; + +import { ExpandableSection, HEADER_ITEMS_LOADING } from '../expandable_section'; + import { getColumnData, ACTUAL_CLASS_ID, @@ -47,7 +47,7 @@ import { getTrailingControlColumns, } from './column_data'; -interface Props { +export interface EvaluatePanelProps { jobConfig: DataFrameAnalyticsConfig; jobStatus?: DATA_FRAME_TASK_STATE; searchQuery: ResultsSearchQuery; @@ -90,7 +90,7 @@ function getHelpText(dataSubsetTitle: string) { return helpText; } -export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) => { +export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) => { const { services: { docLinks }, } = useMlKibana(); @@ -272,10 +272,6 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) return {columnId === ACTUAL_CLASS_ID ? cellValue : accuracy}; }; - if (isLoading === true) { - return ; - } - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; const showTrailingColumns = columnsData.length > MAX_COLUMNS; @@ -288,137 +284,159 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) showTrailingColumns === true && showFullColumns === false ? MAX_COLUMNS : columnsData.length; return ( - -
- - - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle', - { - defaultMessage: 'Evaluation of classification job ID {jobId}', - values: { jobId: jobConfig.id }, - } - )} - - - - {jobStatus !== undefined && ( - - {getTaskStateBadge(jobStatus)} - - )} - - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.classificationExploration.classificationDocsLink', - { - defaultMessage: 'Classification evaluation docs ', - } - )} - - - -
- {error !== null && } - {error === null && ( - -
- - - {getHelpText(dataSubsetTitle)} - - - - - -
- {docsCount !== null && ( - - - - )} - {/* BEGIN TABLE ELEMENTS */} - -
-
- - - -
-
- {columns.length > 0 && columnsData.length > 0 && ( + <> + + } + docsLink={ + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.classificationDocsLink', + { + defaultMessage: 'Classification evaluation docs ', + } + )} + + } + headerItems={ + !isLoading + ? [ + ...(jobStatus !== undefined + ? [ + { + id: 'jobStatus', + label: i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.evaluateJobStatusLabel', + { + defaultMessage: 'Job status', + } + ), + value: jobStatus, + }, + ] + : []), + ...(docsCount !== null + ? [ + { + id: 'docsEvaluated', + label: i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.generalizationDocsCount', + { + defaultMessage: '{docsCount, plural, one {doc} other {docs}} evaluated', + values: { docsCount }, + } + ), + value: docsCount, + }, + ] + : []), + ] + : HEADER_ITEMS_LOADING + } + contentPadding={true} + content={ + !isLoading ? ( + <> + {error !== null && } + {error === null && ( <> -
- - + + {getHelpText(dataSubsetTitle)} + + + - + + + {/* BEGIN TABLE ELEMENTS */} + +
+
+ + + +
+
+ {columns.length > 0 && columnsData.length > 0 && ( + <> +
+ + + +
+ + + + )} +
- - )} -
-
- - )} - {/* END TABLE ELEMENTS */} - + {/* END TABLE ELEMENTS */} + + ) : null + } + /> + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.scss index e296744b2737d..c1c80e8dbd2c4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.scss @@ -1,3 +1,7 @@ .mlExpandableSection { padding: 0 $euiSizeS $euiSizeS $euiSizeS; } + +.mlExpandableSection-contentPadding { + padding: $euiSizeS; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.tsx index 97fb8fd29e5a7..fa7538b580334 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section.tsx @@ -29,9 +29,13 @@ const isHeaderItems = (arg: any): arg is HeaderItem[] => { return Array.isArray(arg); }; +export const HEADER_ITEMS_LOADING = 'header_items_loading'; + export interface ExpandableSectionProps { content: ReactNode; - headerItems?: HeaderItem[] | 'loading'; + contentPadding?: boolean; + docsLink?: ReactNode; + headerItems?: HeaderItem[] | typeof HEADER_ITEMS_LOADING; isExpanded?: boolean; dataTestId: string; title: ReactNode; @@ -45,8 +49,10 @@ export const ExpandableSection: FC = ({ // callback. isExpanded: isExpandedDefault = true, content, + contentPadding = false, dataTestId, title, + docsLink, }) => { const [isExpanded, setIsExpanded] = useState(isExpandedDefault); const toggleExpanded = () => { @@ -56,16 +62,21 @@ export const ExpandableSection: FC = ({ return (
- - {title} - - {headerItems === 'loading' && } + + + + {title} + + + {docsLink !== undefined && {docsLink}} + + {headerItems === HEADER_ITEMS_LOADING && } {isHeaderItems(headerItems) && ( {headerItems.map(({ label, value, id }) => ( @@ -82,13 +93,19 @@ export const ExpandableSection: FC = ({ {value} )} - {label === undefined && value} + {label === undefined && ( + + {value} + + )} ))} )}
- {isExpanded && content} + {isExpanded && ( +
{content}
+ )}
); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_analytics.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_analytics.tsx new file mode 100644 index 0000000000000..0d8a0df30b4e0 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_analytics.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState, FC } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiHorizontalRule, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; + +import type { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; + +import { ml } from '../../../../../services/ml_api_service'; + +import { getAnalysisType } from '../../../../common'; + +import { isGetDataFrameAnalyticsStatsResponseOk } from '../../../analytics_management/services/analytics_service/get_analytics'; +import { + DataFrameAnalyticsListRow, + DATA_FRAME_MODE, +} from '../../../analytics_management/components/analytics_list/common'; +import { ExpandedRow } from '../../../analytics_management/components/analytics_list/expanded_row'; + +import { + ExpandableSection, + ExpandableSectionProps, + HEADER_ITEMS_LOADING, +} from './expandable_section'; + +const getAnalyticsSectionHeaderItems = ( + expandedRowItem: DataFrameAnalyticsListRow | undefined +): ExpandableSectionProps['headerItems'] => { + if (expandedRowItem === undefined) { + return HEADER_ITEMS_LOADING; + } + + const sourceIndex = Array.isArray(expandedRowItem.config.source.index) + ? expandedRowItem.config.source.index.join() + : expandedRowItem.config.source.index; + + return [ + { + id: 'analysisTypeLabel', + label: ( + + ), + value: expandedRowItem.job_type, + }, + { + id: 'analysisSourceIndexLabel', + label: ( + + ), + value: sourceIndex, + }, + { + id: 'analysisDestinationIndexLabel', + label: ( + + ), + value: expandedRowItem.config.dest.index, + }, + ]; +}; + +interface ExpandableSectionAnalyticsProps { + jobId: string; +} + +export const ExpandableSectionAnalytics: FC = ({ jobId }) => { + const [expandedRowItem, setExpandedRowItem] = useState(); + + const fetchStats = async () => { + const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId); + const analyticsStats = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(jobId); + + const config = analyticsConfigs.data_frame_analytics[0]; + const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats) + ? analyticsStats.data_frame_analytics[0] + : undefined; + + if (stats === undefined) { + return; + } + + const newExpandedRowItem: DataFrameAnalyticsListRow = { + checkpointing: {}, + config, + id: config.id, + job_type: getAnalysisType(config.analysis) as DataFrameAnalysisConfigType, + mode: DATA_FRAME_MODE.BATCH, + state: stats.state, + stats, + }; + + setExpandedRowItem(newExpandedRowItem); + }; + + useEffect(() => { + fetchStats(); + }, [jobId]); + + const analyticsSectionHeaderItems = getAnalyticsSectionHeaderItems(expandedRowItem); + const analyticsSectionContent = ( + <> + + {expandedRowItem === undefined && ( + + + + + + )} + {expandedRowItem !== undefined && } + + ); + + return ( + <> + + } + /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_results.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_results.tsx new file mode 100644 index 0000000000000..e01a291b27385 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_results.tsx @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiDataGridColumn, EuiSpacer, EuiText } from '@elastic/eui'; + +import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; + +import { + isClassificationAnalysis, + isRegressionAnalysis, +} from '../../../../../../../common/util/analytics_utils'; + +import { getToastNotifications } from '../../../../../util/dependency_cache'; +import { useColorRange, ColorRangeLegend } from '../../../../../components/color_range_legend'; +import { DataGrid, UseIndexDataReturnType } from '../../../../../components/data_grid'; +import { SavedSearchQuery } from '../../../../../contexts/ml'; + +import { defaultSearchQuery, DataFrameAnalyticsConfig, SEARCH_SIZE } from '../../../../common'; + +import { + ExpandableSection, + ExpandableSectionProps, + HEADER_ITEMS_LOADING, +} from '../expandable_section'; +import { IndexPatternPrompt } from '../index_pattern_prompt'; + +const showingDocs = i18n.translate( + 'xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText', + { + defaultMessage: 'Showing documents for which predictions exist', + } +); + +const showingFirstDocs = i18n.translate( + 'xpack.ml.dataframe.analytics.explorationResults.firstDocumentsShownHelpText', + { + defaultMessage: 'Showing first {searchSize} documents for which predictions exist', + values: { searchSize: SEARCH_SIZE }, + } +); + +const getResultsSectionHeaderItems = ( + columnsWithCharts: EuiDataGridColumn[], + tableItems: Array>, + rowCount: number, + colorRange?: ReturnType +): ExpandableSectionProps['headerItems'] => { + return columnsWithCharts.length > 0 && tableItems.length > 0 + ? [ + { + id: 'explorationTableTotalDocs', + label: ( + + ), + value: rowCount, + }, + ...(colorRange !== undefined + ? [ + { + id: 'colorRangeLegend', + value: ( + + ), + }, + ] + : []), + ] + : HEADER_ITEMS_LOADING; +}; + +interface ExpandableSectionResultsProps { + colorRange?: ReturnType; + indexData: UseIndexDataReturnType; + indexPattern?: IndexPattern; + jobConfig?: DataFrameAnalyticsConfig; + needsDestIndexPattern: boolean; + searchQuery: SavedSearchQuery; +} + +export const ExpandableSectionResults: FC = ({ + colorRange, + indexData, + indexPattern, + jobConfig, + needsDestIndexPattern, + searchQuery, +}) => { + const { columnsWithCharts, tableItems } = indexData; + + // Results section header items and content + const resultsSectionHeaderItems = getResultsSectionHeaderItems( + columnsWithCharts, + tableItems, + indexData.rowCount, + colorRange + ); + const resultsSectionContent = ( + <> + {jobConfig !== undefined && needsDestIndexPattern && ( +
+ +
+ )} + {jobConfig !== undefined && + (isRegressionAnalysis(jobConfig.analysis) || + isClassificationAnalysis(jobConfig.analysis)) && ( + + {tableItems.length === SEARCH_SIZE ? showingFirstDocs : showingDocs} + + )} + {(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) && + indexPattern !== undefined && ( + <> + {columnsWithCharts.length > 0 && tableItems.length > 0 && ( + + )} + + )} + + ); + + return ( + <> + + } + /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/index.ts index ad7ce84902e87..3d9237922e19d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/index.ts @@ -4,4 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ExpandableSection, ExpandableSectionProps } from './expandable_section'; +export { + ExpandableSection, + ExpandableSectionProps, + HEADER_ITEMS_LOADING, +} from './expandable_section'; +export { ExpandableSectionAnalytics } from './expandable_section_analytics'; +export { ExpandableSectionResults } from './expandable_section_results'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 6b1b3fc1bb47f..b03777fef6bd4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -4,20 +4,49 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; -import { useResultsViewConfig, DataFrameAnalyticsConfig } from '../../../../common'; -import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics'; +import { useUrlState } from '../../../../../util/url_state'; + +import { + defaultSearchQuery, + getDefaultTrainingFilterQuery, + useResultsViewConfig, + DataFrameAnalyticsConfig, +} from '../../../../common'; +import { ResultsSearchQuery } from '../../../../common/analytics'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; +import { ExpandableSectionAnalytics } from '../expandable_section'; import { ExplorationResultsTable } from '../exploration_results_table'; +import { ExplorationQueryBar } from '../exploration_query_bar'; import { JobConfigErrorCallout } from '../job_config_error_callout'; import { LoadingPanel } from '../loading_panel'; import { FeatureImportanceSummaryPanelProps } from '../total_feature_importance_summary/feature_importance_summary'; +const filters = { + options: [ + { + id: 'training', + label: i18n.translate('xpack.ml.dataframe.analytics.explorationResults.trainingSubsetLabel', { + defaultMessage: 'Training', + }), + }, + { + id: 'testing', + label: i18n.translate('xpack.ml.dataframe.analytics.explorationResults.testingSubsetLabel', { + defaultMessage: 'Testing', + }), + }, + ], + columnId: 'ml.is_training', + key: { training: true, testing: false }, +}; + export interface EvaluatePanelProps { jobConfig: DataFrameAnalyticsConfig; jobStatus?: DATA_FRAME_TASK_STATE; @@ -50,7 +79,25 @@ export const ExplorationPageWrapper: FC = ({ needsDestIndexPattern, totalFeatureImportance, } = useResultsViewConfig(jobId); + const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const [globalState, setGlobalState] = useUrlState('_g'); + const [defaultQueryString, setDefaultQueryString] = useState(); + + useEffect(() => { + if (defaultIsTraining !== undefined && jobConfig !== undefined) { + // Apply defaultIsTraining filter + setSearchQuery( + getDefaultTrainingFilterQuery(jobConfig.dest.results_field, defaultIsTraining) + ); + setDefaultQueryString(`${jobConfig.dest.results_field}.is_training : ${defaultIsTraining}`); + // Clear defaultIsTraining from url + setGlobalState('ml', { + analysisType: globalState.ml.analysisType, + jobId: globalState.ml.jobId, + }); + } + }, [jobConfig?.dest.results_field]); if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) { return ( @@ -61,21 +108,54 @@ export const ExplorationPageWrapper: FC = ({ /> ); } + return ( <> + {typeof jobConfig?.description !== 'undefined' && ( + <> + {jobConfig?.description} + + + )} + + {indexPattern !== undefined && ( + <> + + + + + + + + + + + + + )} + {isLoadingJobConfig === true && jobConfig === undefined && } {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( - + )} + {isLoadingJobConfig === true && totalFeatureImportance === undefined && } {isLoadingJobConfig === false && totalFeatureImportance !== undefined && ( <> - )} - + {isLoadingJobConfig === true && jobConfig === undefined && } + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( + + )} + {isLoadingJobConfig === true && jobConfig === undefined && } {isLoadingJobConfig === false && jobConfig !== undefined && @@ -86,9 +166,7 @@ export const ExplorationPageWrapper: FC = ({ jobConfig={jobConfig} jobStatus={jobStatus} needsDestIndexPattern={needsDestIndexPattern} - setEvaluateSearchQuery={setSearchQuery} - title={title} - defaultIsTraining={defaultIsTraining} + searchQuery={searchQuery} /> )} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx index bd4079272c56e..a6e95269b3633 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx @@ -4,118 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useEffect, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import React, { FC } from 'react'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; -import { DataGrid } from '../../../../../components/data_grid'; -import { SavedSearchQuery } from '../../../../../contexts/ml'; import { getToastNotifications } from '../../../../../util/dependency_cache'; - -import { - DataFrameAnalyticsConfig, - MAX_COLUMNS, - SEARCH_SIZE, - defaultSearchQuery, - getAnalysisType, - getDefaultTrainingFilterQuery, -} from '../../../../common'; -import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/use_columns'; -import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; -import { ExplorationTitle } from '../exploration_title'; -import { ExplorationQueryBar } from '../exploration_query_bar'; -import { IndexPatternPrompt } from '../index_pattern_prompt'; - -import { useExplorationResults } from './use_exploration_results'; import { useMlKibana } from '../../../../../contexts/kibana'; -import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; -import { useUrlState } from '../../../../../util/url_state'; -const showingDocs = i18n.translate( - 'xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText', - { - defaultMessage: 'Showing documents for which predictions exist', - } -); +import { DataFrameAnalyticsConfig } from '../../../../common'; +import { ResultsSearchQuery } from '../../../../common/analytics'; -const showingFirstDocs = i18n.translate( - 'xpack.ml.dataframe.analytics.explorationResults.firstDocumentsShownHelpText', - { - defaultMessage: 'Showing first {searchSize} documents for which predictions exist', - values: { searchSize: SEARCH_SIZE }, - } -); +import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; + +import { ExpandableSectionResults } from '../expandable_section'; -const filters = { - options: [ - { - id: 'training', - label: i18n.translate('xpack.ml.dataframe.analytics.explorationResults.trainingSubsetLabel', { - defaultMessage: 'Training', - }), - }, - { - id: 'testing', - label: i18n.translate('xpack.ml.dataframe.analytics.explorationResults.testingSubsetLabel', { - defaultMessage: 'Testing', - }), - }, - ], - columnId: 'ml.is_training', - key: { training: true, testing: false }, -}; +import { useExplorationResults } from './use_exploration_results'; interface Props { indexPattern: IndexPattern; jobConfig: DataFrameAnalyticsConfig; jobStatus?: DATA_FRAME_TASK_STATE; needsDestIndexPattern: boolean; - setEvaluateSearchQuery: React.Dispatch>; - title: string; - defaultIsTraining?: boolean; + searchQuery: ResultsSearchQuery; } export const ExplorationResultsTable: FC = React.memo( - ({ - indexPattern, - jobConfig, - jobStatus, - needsDestIndexPattern, - setEvaluateSearchQuery, - title, - defaultIsTraining, - }) => { + ({ indexPattern, jobConfig, jobStatus, needsDestIndexPattern, searchQuery }) => { const { services: { mlServices: { mlApiServices }, }, } = useMlKibana(); - const [globalState, setGlobalState] = useUrlState('_g'); - const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); - const [defaultQueryString, setDefaultQueryString] = useState(); - - useEffect(() => { - setEvaluateSearchQuery(searchQuery); - }, [JSON.stringify(searchQuery)]); - - useEffect(() => { - if (defaultIsTraining !== undefined) { - // Apply defaultIsTraining filter - setSearchQuery( - getDefaultTrainingFilterQuery(jobConfig.dest.results_field, defaultIsTraining) - ); - setDefaultQueryString(`${jobConfig.dest.results_field}.is_training : ${defaultIsTraining}`); - // Clear defaultIsTraining from url - setGlobalState('ml', { - analysisType: globalState.ml.analysisType, - jobId: globalState.ml.jobId, - }); - } - }, []); - - const analysisType = getAnalysisType(jobConfig.analysis); const classificationData = useExplorationResults( indexPattern, @@ -125,83 +44,20 @@ export const ExplorationResultsTable: FC = React.memo( mlApiServices ); - const docFieldsCount = classificationData.columnsWithCharts.length; - const { columnsWithCharts, tableItems, visibleColumns } = classificationData; - if (jobConfig === undefined || classificationData === undefined) { return null; } return ( - - {needsDestIndexPattern && } - - - - - - - {jobStatus !== undefined && ( - - {getTaskStateBadge(jobStatus)} - - )} - - - - - - {docFieldsCount > MAX_COLUMNS && ( - - {i18n.translate( - 'xpack.ml.dataframe.analytics.explorationResults.fieldSelection', - { - defaultMessage: - '{selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}} selected', - values: { selectedFieldsLength: visibleColumns.length, docFieldsCount }, - } - )} - - )} - - - - - {(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) && ( - - - - - - - - - - - - - - - - - )} - +
+ +
); } ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/exploration_title.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/exploration_title.tsx deleted file mode 100644 index f06c88c73df71..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/exploration_title.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC } from 'react'; - -import { EuiTitle } from '@elastic/eui'; - -export const ExplorationTitle: FC<{ title: string }> = ({ title }) => ( - - {title} - -); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/index_pattern_prompt/index_pattern_prompt.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/index_pattern_prompt/index_pattern_prompt.tsx index f478dc639da2f..0353129212b0a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/index_pattern_prompt/index_pattern_prompt.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/index_pattern_prompt/index_pattern_prompt.tsx @@ -6,7 +6,7 @@ import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiLink, EuiText } from '@elastic/eui'; import { useMlKibana } from '../../../../../contexts/kibana'; interface Props { @@ -42,7 +42,6 @@ export const IndexPatternPrompt: FC = ({ destIndex }) => { }} /> - ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/job_config_error_callout/job_config_error_callout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/job_config_error_callout/job_config_error_callout.tsx index 959f2d18d99fe..261438cec7292 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/job_config_error_callout/job_config_error_callout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/job_config_error_callout/job_config_error_callout.tsx @@ -10,7 +10,6 @@ import { EuiCallOut, EuiLink, EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { ExplorationTitle } from '../exploration_title'; import { useMlKibana } from '../../../../../contexts/kibana'; const jobConfigErrorTitle = i18n.translate('xpack.ml.dataframe.analytics.jobConfig.errorTitle', { @@ -63,7 +62,6 @@ export const JobConfigErrorCallout: FC = ({ return ( - ( - - - + <> + + + + + ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx index 7d7f5efcae321..8fc2486599755 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx @@ -4,124 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, FC } from 'react'; +import React, { useState, FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - EuiDataGridColumn, - EuiHorizontalRule, - EuiLoadingSpinner, - EuiSpacer, - EuiText, -} from '@elastic/eui'; - -import type { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; +import { EuiSpacer, EuiText } from '@elastic/eui'; import { useColorRange, COLOR_RANGE, COLOR_RANGE_SCALE, } from '../../../../../components/color_range_legend'; -import { ColorRangeLegend } from '../../../../../components/color_range_legend'; -import { DataGrid } from '../../../../../components/data_grid'; import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { getToastNotifications } from '../../../../../util/dependency_cache'; -import { ml } from '../../../../../services/ml_api_service'; - -import { getAnalysisType, defaultSearchQuery, useResultsViewConfig } from '../../../../common'; - -import { isGetDataFrameAnalyticsStatsResponseOk } from '../../../analytics_management/services/analytics_service/get_analytics'; -import { - DataFrameAnalyticsListRow, - DATA_FRAME_MODE, -} from '../../../analytics_management/components/analytics_list/common'; -import { ExpandedRow } from '../../../analytics_management/components/analytics_list/expanded_row'; +import { defaultSearchQuery, useResultsViewConfig } from '../../../../common'; -import { ExpandableSection, ExpandableSectionProps } from '../expandable_section'; +import { ExpandableSectionAnalytics, ExpandableSectionResults } from '../expandable_section'; import { ExplorationQueryBar } from '../exploration_query_bar'; -import { IndexPatternPrompt } from '../index_pattern_prompt'; import { getFeatureCount } from './common'; import { useOutlierData } from './use_outlier_data'; -const getAnalyticsSectionHeaderItems = ( - expandedRowItem: DataFrameAnalyticsListRow | undefined -): ExpandableSectionProps['headerItems'] => { - return expandedRowItem !== undefined - ? [ - { - id: 'analysisTypeLabel', - label: ( - - ), - value: expandedRowItem.job_type, - }, - { - id: 'analysisSourceIndexLabel', - label: ( - - ), - value: expandedRowItem.config.source.index, - }, - { - id: 'analysisDestinationIndexLabel', - label: ( - - ), - value: expandedRowItem.config.dest.index, - }, - ] - : 'loading'; -}; - -const getResultsSectionHeaderItems = ( - columnsWithCharts: EuiDataGridColumn[], - tableItems: Array>, - rowCount: number, - colorRange: ReturnType -): ExpandableSectionProps['headerItems'] => { - return columnsWithCharts.length > 0 && tableItems.length > 0 - ? [ - { - id: 'explorationTableTotalDocs', - label: ( - - ), - value: rowCount, - }, - { - id: 'colorRangeLegend', - value: ( - - ), - }, - ] - : 'loading'; -}; - export type TableItem = Record; interface ExplorationProps { @@ -141,89 +42,14 @@ export const OutlierExploration: FC = React.memo(({ jobId }) = jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, tableItems) : 1 ); - const [expandedRowItem, setExpandedRowItem] = useState(); - - const fetchStats = async () => { - const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId); - const analyticsStats = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(jobId); - - const config = analyticsConfigs.data_frame_analytics[0]; - const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats) - ? analyticsStats.data_frame_analytics[0] - : undefined; - - if (stats === undefined) { - return; - } - - const newExpandedRowItem: DataFrameAnalyticsListRow = { - checkpointing: {}, - config, - id: config.id, - job_type: getAnalysisType(config.analysis) as DataFrameAnalysisConfigType, - mode: DATA_FRAME_MODE.BATCH, - state: stats.state, - stats, - }; - - setExpandedRowItem(newExpandedRowItem); - }; - - useEffect(() => { - fetchStats(); - }, [jobConfig?.id]); - - // Analytics section header items and content - const analyticsSectionHeaderItems = getAnalyticsSectionHeaderItems(expandedRowItem); - const analyticsSectionContent = ( - <> - - {expandedRowItem === undefined && ( - - - - - - )} - {(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) && - indexPattern !== undefined && - jobConfig !== undefined && - columnsWithCharts.length > 0 && - tableItems.length > 0 && - expandedRowItem !== undefined && } - - ); - - // Results section header items and content - const resultsSectionHeaderItems = getResultsSectionHeaderItems( - columnsWithCharts, - tableItems, - outlierData.rowCount, - colorRange - ); - const resultsSectionContent = ( - <> - {jobConfig !== undefined && needsDestIndexPattern && ( - - )} - {(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) && - indexPattern !== undefined && ( - <> - - {columnsWithCharts.length > 0 && tableItems.length > 0 && ( - - )} - - )} - - ); - return ( <> + {typeof jobConfig?.description !== 'undefined' && ( + <> + {jobConfig?.description} + + + )} {(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) && indexPattern !== undefined && ( <> @@ -231,34 +57,15 @@ export const OutlierExploration: FC = React.memo(({ jobId }) = )} - - - } - /> - - - - - } + {typeof jobConfig?.id === 'string' && } + - ); }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx index 197160a1be4d9..4350583a907af 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx @@ -11,7 +11,6 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, - EuiPanel, EuiSpacer, EuiText, EuiTitle, @@ -27,9 +26,7 @@ import { Eval, DataFrameAnalyticsConfig, } from '../../../../common'; -import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/use_columns'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; -import { EvaluateStat } from './evaluate_stat'; import { isResultsSearchBoolQuery, isRegressionEvaluateResponse, @@ -38,6 +35,10 @@ import { EMPTY_STAT, } from '../../../../common/analytics'; +import { ExpandableSection } from '../expandable_section'; + +import { EvaluateStat } from './evaluate_stat'; + interface Props { jobConfig: DataFrameAnalyticsConfig; jobStatus?: DATA_FRAME_TASK_STATE; @@ -219,30 +220,16 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) }, [JSON.stringify(searchQuery)]); return ( - - - - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.evaluateJobIdTitle', - { - defaultMessage: 'Evaluation of regression job ID {jobId}', - values: { jobId: jobConfig.id }, - } - )} - - - - {jobStatus !== undefined && ( - - {getTaskStateBadge(jobStatus)} - - )} - - - - + <> + + } + docsLink={ = ({ jobConfig, jobStatus, searchQuery }) } )} - - - - - - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle', + } + headerItems={ + jobStatus !== undefined + ? [ { - defaultMessage: 'Generalization error', - } - )} - - - {generalizationDocsCount !== null && ( - - - {isTrainingFilter === true && generalizationDocsCount === 0 && ( - - )} - - )} - - + id: 'jobStatus', + label: i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.evaluateJobStatusLabel', + { + defaultMessage: 'Job status', + } + ), + value: jobStatus, + }, + ] + : [] + } + contentPadding={true} + content={ + - - {/* First row stats */} - - - - - - - - - - - {/* Second row stats */} + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle', + { + defaultMessage: 'Generalization error', + } + )} + + + {generalizationDocsCount !== null && ( + + + {isTrainingFilter === true && generalizationDocsCount === 0 && ( + + )} + + )} + + - + + {/* First row stats */} - + + + + + + + + + {/* Second row stats */} - + + + + + + + + + {generalizationEval.error !== null && ( + + + {isTrainingFilter === true && + generalizationDocsCount === 0 && + generalizationEval.error.includes('No documents found') + ? i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.evaluateNoTestingDocsError', + { + defaultMessage: 'No testing documents found', + } + ) + : generalizationEval.error} + + + )} - {generalizationEval.error !== null && ( - - - {isTrainingFilter === true && - generalizationDocsCount === 0 && - generalizationEval.error.includes('No documents found') - ? i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.evaluateNoTestingDocsError', - { - defaultMessage: 'No testing documents found', - } - ) - : generalizationEval.error} + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.trainingErrorTitle', + { + defaultMessage: 'Training error', + } + )} + + + {trainingDocsCount !== null && ( + + + {isTrainingFilter === false && trainingDocsCount === 0 && ( + + )} - - )} - - - - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.trainingErrorTitle', - { - defaultMessage: 'Training error', - } - )} - - - {trainingDocsCount !== null && ( - - - {isTrainingFilter === false && trainingDocsCount === 0 && ( - )} - - )} - - - - - {/* First row stats */} + + - + + {/* First row stats */} - + + + + + + + + + {/* Second row stats */} - - - - - {/* Second row stats */} - - - - - - - + + + + + + + + + {trainingEval.error !== null && ( + + + {isTrainingFilter === false && + trainingDocsCount === 0 && + trainingEval.error.includes('No documents found') + ? i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.evaluateNoTrainingDocsError', + { + defaultMessage: 'No training documents found', + } + ) + : trainingEval.error} + + + )} - {trainingEval.error !== null && ( - - - {isTrainingFilter === false && - trainingDocsCount === 0 && - trainingEval.error.includes('No documents found') - ? i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.evaluateNoTrainingDocsError', - { - defaultMessage: 'No training documents found', - } - ) - : trainingEval.error} - - - )} - - - + } + /> + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/total_feature_importance_summary/feature_importance_summary.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/total_feature_importance_summary/feature_importance_summary.tsx index f7ac717caef2f..32ea2cfe8145f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/total_feature_importance_summary/feature_importance_summary.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/total_feature_importance_summary/feature_importance_summary.tsx @@ -5,15 +5,7 @@ */ import React, { FC, useCallback, useMemo } from 'react'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiIconTip, - EuiPanel, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Chart, @@ -38,6 +30,9 @@ import { } from '../../../../../../../common/types/feature_importance'; import { useMlKibana } from '../../../../../contexts/kibana'; + +import { ExpandableSection } from '../expandable_section'; + const { euiColorMediumShade } = euiVars; const axisColor = euiColorMediumShade; @@ -194,71 +189,67 @@ export const FeatureImportanceSummaryPanel: FC Number(d.toPrecision(3)).toString(), []); return ( - -
- - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - -
+ <> + + } + docsLink={ + + + + } + headerItems={[ + { + id: 'FeatureImportanceSummary', + value: tooltipContent, + }, + ]} + content={ + + + + + + + + } + /> + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx index d2767a9612e3b..0144d369c46f6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx @@ -12,7 +12,6 @@ import { EuiPageContentBody, EuiPageContentHeader, EuiPageContentHeaderSection, - EuiSpacer, EuiTitle, } from '@elastic/eui'; @@ -42,7 +41,6 @@ export const Page: FC<{ - {analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && ( )} diff --git a/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js b/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js index a0e9c33e42dfa..6e23d652b5c9f 100644 --- a/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js @@ -18,6 +18,7 @@ import { ml } from '../../../services/ml_api_service'; import { mlJobService } from '../../../services/job_service'; import { escapeForElasticsearchQuery } from '../../../util/string_utils'; import { getSavedObjectsClient, getGetUrlGenerator } from '../../../util/dependency_cache'; +import { getProcessedFields } from '../../../components/data_grid'; export function getNewCustomUrlDefaults(job, dashboards, indexPatterns) { // Returns the settings object in the format used by the custom URL editor @@ -329,7 +330,7 @@ export function getTestUrl(job, customUrl) { }); } else { if (response.hits.total.value > 0) { - testDoc = response.hits.hits[0]._source; + testDoc = getProcessedFields(response.hits.hits[0].fields); } } diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 0971b47605135..4aa1f7ef81d59 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -509,10 +509,10 @@ class JobService { fields[job.data_description.time_field] = {}; } - // console.log('fields: ', fields); const fieldsList = Object.keys(fields); if (fieldsList.length) { - body._source = fieldsList; + body.fields = fieldsList; + body._source = false; } } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 4c87c3b374ff3..448d39db3e444 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -672,7 +672,7 @@ class TimeseriesChartIntl extends Component { // if annotations are present, we extend yMax to avoid overlap // between annotation labels, chart lines and anomalies. - if (focusAnnotationData && focusAnnotationData.length > 0) { + if (showAnnotations && focusAnnotationData && focusAnnotationData.length > 0) { const levels = getAnnotationLevels(focusAnnotationData); const maxLevel = d3.max(Object.keys(levels).map((key) => levels[key])); // TODO needs revisiting to be a more robust normalization diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index 6b9f30b2ae00b..9e6c6f1552bad 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -56,18 +56,25 @@ export function categorizationExamplesProvider({ } } } - const { body } = await asCurrentUser.search>({ index: indexPatternTitle, size, body: { - _source: categorizationFieldName, + fields: [categorizationFieldName], + _source: false, query, sort: ['_doc'], }, }); - const tempExamples = body.hits.hits.map(({ _source }) => _source[categorizationFieldName]); + // hit.fields can be undefined if value is originally null + const tempExamples = body.hits.hits.map(({ fields }) => + fields && + Array.isArray(fields[categorizationFieldName]) && + fields[categorizationFieldName].length > 0 + ? fields[categorizationFieldName][0] + : null + ); validationResults.createNullValueResult(tempExamples); @@ -81,7 +88,6 @@ export function categorizationExamplesProvider({ const examplesWithTokens = await getTokens(CHUNK_SIZE, allExamples, analyzer); return { examples: examplesWithTokens }; } catch (err) { - // console.log('dropping to 50 chunk size'); // if an error is thrown when loading the tokens, lower the chunk size by half and try again // the error may have been caused by too many tokens being found. // the _analyze endpoint has a maximum of 10000 tokens. diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts index 60595ccedff45..5845064218ad8 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts @@ -123,15 +123,19 @@ export class ValidationResults { public createNullValueResult(examples: Array) { const nullCount = examples.filter((e) => e === null).length; - if (nullCount / examples.length >= NULL_COUNT_PERCENT_LIMIT) { - this._results.push({ - id: VALIDATION_RESULT.NULL_VALUES, - valid: CATEGORY_EXAMPLES_VALIDATION_STATUS.PARTIALLY_VALID, - message: i18n.translate('xpack.ml.models.jobService.categorization.messages.nullValues', { - defaultMessage: 'More than {percent}% of field values are null.', - values: { percent: NULL_COUNT_PERCENT_LIMIT * 100 }, - }), - }); + // if all values are null, VALIDATION_RESULT.NO_EXAMPLES will be raised + // so we don't need to display this warning as well + if (nullCount !== examples.length) { + if (nullCount / examples.length >= NULL_COUNT_PERCENT_LIMIT) { + this._results.push({ + id: VALIDATION_RESULT.NULL_VALUES, + valid: CATEGORY_EXAMPLES_VALIDATION_STATUS.PARTIALLY_VALID, + message: i18n.translate('xpack.ml.models.jobService.categorization.messages.nullValues', { + defaultMessage: 'More than {percent}% of field values are null.', + values: { percent: NULL_COUNT_PERCENT_LIMIT * 100 }, + }), + }); + } } } diff --git a/x-pack/plugins/monitoring/common/constants.ts b/x-pack/plugins/monitoring/common/constants.ts index 860f6439f3fdf..76d9e7517b6ab 100644 --- a/x-pack/plugins/monitoring/common/constants.ts +++ b/x-pack/plugins/monitoring/common/constants.ts @@ -236,6 +236,7 @@ export const ALERT_NODES_CHANGED = `${ALERT_PREFIX}alert_nodes_changed`; export const ALERT_ELASTICSEARCH_VERSION_MISMATCH = `${ALERT_PREFIX}alert_elasticsearch_version_mismatch`; export const ALERT_KIBANA_VERSION_MISMATCH = `${ALERT_PREFIX}alert_kibana_version_mismatch`; export const ALERT_LOGSTASH_VERSION_MISMATCH = `${ALERT_PREFIX}alert_logstash_version_mismatch`; +export const ALERT_MEMORY_USAGE = `${ALERT_PREFIX}alert_jvm_memory_usage`; export const ALERT_MISSING_MONITORING_DATA = `${ALERT_PREFIX}alert_missing_monitoring_data`; /** @@ -250,6 +251,7 @@ export const ALERTS = [ ALERT_ELASTICSEARCH_VERSION_MISMATCH, ALERT_KIBANA_VERSION_MISMATCH, ALERT_LOGSTASH_VERSION_MISMATCH, + ALERT_MEMORY_USAGE, ALERT_MISSING_MONITORING_DATA, ]; diff --git a/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx b/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx new file mode 100644 index 0000000000000..dd60967a3458b --- /dev/null +++ b/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { validate } from '../components/duration/validation'; +import { Expression, Props } from '../components/duration/expression'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../triggers_actions_ui/public/types'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { MemoryUsageAlert } from '../../../server/alerts'; + +export function createMemoryUsageAlertType(): AlertTypeModel { + return { + id: MemoryUsageAlert.TYPE, + name: MemoryUsageAlert.LABEL, + iconClass: 'bell', + alertParamsExpression: (props: Props) => ( + + ), + validate, + defaultActionMessage: '{{context.internalFullMessage}}', + requiresAppContext: true, + }; +} diff --git a/x-pack/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js b/x-pack/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js index 667f64458b8f9..13324ba3ecac9 100644 --- a/x-pack/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js +++ b/x-pack/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js @@ -41,6 +41,7 @@ import { ALERT_CLUSTER_HEALTH, ALERT_CPU_USAGE, ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, ALERT_NODES_CHANGED, ALERT_ELASTICSEARCH_VERSION_MISMATCH, ALERT_MISSING_MONITORING_DATA, @@ -160,6 +161,7 @@ const OVERVIEW_PANEL_ALERTS = [ALERT_CLUSTER_HEALTH, ALERT_LICENSE_EXPIRATION]; const NODES_PANEL_ALERTS = [ ALERT_CPU_USAGE, ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, ALERT_NODES_CHANGED, ALERT_ELASTICSEARCH_VERSION_MISMATCH, ALERT_MISSING_MONITORING_DATA, diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts index f4f66185346e8..2af23f3d7b316 100644 --- a/x-pack/plugins/monitoring/public/plugin.ts +++ b/x-pack/plugins/monitoring/public/plugin.ts @@ -26,6 +26,7 @@ import { createCpuUsageAlertType } from './alerts/cpu_usage_alert'; import { createMissingMonitoringDataAlertType } from './alerts/missing_monitoring_data_alert'; import { createLegacyAlertTypes } from './alerts/legacy_alert'; import { createDiskUsageAlertType } from './alerts/disk_usage_alert'; +import { createMemoryUsageAlertType } from './alerts/memory_usage_alert'; interface MonitoringSetupPluginDependencies { home?: HomePublicPluginSetup; @@ -72,12 +73,15 @@ export class MonitoringPlugin }); } - plugins.triggers_actions_ui.alertTypeRegistry.register(createCpuUsageAlertType()); - plugins.triggers_actions_ui.alertTypeRegistry.register(createMissingMonitoringDataAlertType()); - plugins.triggers_actions_ui.alertTypeRegistry.register(createDiskUsageAlertType()); + const { alertTypeRegistry } = plugins.triggers_actions_ui; + alertTypeRegistry.register(createCpuUsageAlertType()); + alertTypeRegistry.register(createDiskUsageAlertType()); + alertTypeRegistry.register(createMemoryUsageAlertType()); + alertTypeRegistry.register(createMissingMonitoringDataAlertType()); + const legacyAlertTypes = createLegacyAlertTypes(); for (const legacyAlertType of legacyAlertTypes) { - plugins.triggers_actions_ui.alertTypeRegistry.register(legacyAlertType); + alertTypeRegistry.register(legacyAlertType); } const app: App = { diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js index ff7f29c58b2f6..03c0714864f92 100644 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js +++ b/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js @@ -22,6 +22,7 @@ import { ALERT_CPU_USAGE, ALERT_MISSING_MONITORING_DATA, ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, } from '../../../../../common/constants'; function getPageData($injector) { @@ -72,7 +73,12 @@ uiRoutes.when('/elasticsearch/nodes/:node/advanced', { alerts: { shouldFetch: true, options: { - alertTypeIds: [ALERT_CPU_USAGE, ALERT_DISK_USAGE, ALERT_MISSING_MONITORING_DATA], + alertTypeIds: [ + ALERT_CPU_USAGE, + ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, + ALERT_MISSING_MONITORING_DATA, + ], filters: [ { nodeUuid: nodeName, diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js index 15b9b7b4c0e4a..5164e93c266ca 100644 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js +++ b/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js @@ -23,6 +23,7 @@ import { ALERT_CPU_USAGE, ALERT_MISSING_MONITORING_DATA, ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, } from '../../../../common/constants'; uiRoutes.when('/elasticsearch/nodes/:node', { @@ -56,7 +57,12 @@ uiRoutes.when('/elasticsearch/nodes/:node', { alerts: { shouldFetch: true, options: { - alertTypeIds: [ALERT_CPU_USAGE, ALERT_DISK_USAGE, ALERT_MISSING_MONITORING_DATA], + alertTypeIds: [ + ALERT_CPU_USAGE, + ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, + ALERT_MISSING_MONITORING_DATA, + ], filters: [ { nodeUuid: nodeName, diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js index ef807bf9b377d..4f66508c2d30f 100644 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js +++ b/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js @@ -21,6 +21,7 @@ import { ALERT_CPU_USAGE, ALERT_MISSING_MONITORING_DATA, ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, } from '../../../../common/constants'; uiRoutes.when('/elasticsearch/nodes', { @@ -88,7 +89,12 @@ uiRoutes.when('/elasticsearch/nodes', { alerts: { shouldFetch: true, options: { - alertTypeIds: [ALERT_CPU_USAGE, ALERT_DISK_USAGE, ALERT_MISSING_MONITORING_DATA], + alertTypeIds: [ + ALERT_CPU_USAGE, + ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, + ALERT_MISSING_MONITORING_DATA, + ], filters: [ { stackProduct: ELASTICSEARCH_SYSTEM_ID, diff --git a/x-pack/plugins/monitoring/server/alerts/alerts_factory.test.ts b/x-pack/plugins/monitoring/server/alerts/alerts_factory.test.ts index ddc8dcafebd21..f486061109b39 100644 --- a/x-pack/plugins/monitoring/server/alerts/alerts_factory.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/alerts_factory.test.ts @@ -63,6 +63,6 @@ describe('AlertsFactory', () => { it('should get all', () => { const alerts = AlertsFactory.getAll(); - expect(alerts.length).toBe(9); + expect(alerts.length).toBe(10); }); }); diff --git a/x-pack/plugins/monitoring/server/alerts/alerts_factory.ts b/x-pack/plugins/monitoring/server/alerts/alerts_factory.ts index 05a92cea5469b..22c41c9c60038 100644 --- a/x-pack/plugins/monitoring/server/alerts/alerts_factory.ts +++ b/x-pack/plugins/monitoring/server/alerts/alerts_factory.ts @@ -8,6 +8,7 @@ import { CpuUsageAlert, MissingMonitoringDataAlert, DiskUsageAlert, + MemoryUsageAlert, NodesChangedAlert, ClusterHealthAlert, LicenseExpirationAlert, @@ -22,6 +23,7 @@ import { ALERT_CPU_USAGE, ALERT_MISSING_MONITORING_DATA, ALERT_DISK_USAGE, + ALERT_MEMORY_USAGE, ALERT_NODES_CHANGED, ALERT_LOGSTASH_VERSION_MISMATCH, ALERT_KIBANA_VERSION_MISMATCH, @@ -35,6 +37,7 @@ export const BY_TYPE = { [ALERT_CPU_USAGE]: CpuUsageAlert, [ALERT_MISSING_MONITORING_DATA]: MissingMonitoringDataAlert, [ALERT_DISK_USAGE]: DiskUsageAlert, + [ALERT_MEMORY_USAGE]: MemoryUsageAlert, [ALERT_NODES_CHANGED]: NodesChangedAlert, [ALERT_LOGSTASH_VERSION_MISMATCH]: LogstashVersionMismatchAlert, [ALERT_KIBANA_VERSION_MISMATCH]: KibanaVersionMismatchAlert, diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index 61486626040f7..c92291cf72093 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -377,4 +377,12 @@ export class BaseAlert { ) { throw new Error('Child classes must implement `executeActions`'); } + + protected createGlobalStateLink(link: string, clusterUuid: string, ccs?: string) { + const globalState = [`cluster_uuid:${clusterUuid}`]; + if (ccs) { + globalState.push(`ccs:${ccs}`); + } + return `${this.kibanaUrl}/app/monitoring#/${link}?_g=(${globalState.toString()})`; + } } diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts index 495fe993cca1b..a53ae1f9d0dd5 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts @@ -78,7 +78,6 @@ describe('CpuUsageAlert', () => { }; const kibanaUrl = 'http://localhost:5601'; - const hasScheduledActions = jest.fn(); const replaceState = jest.fn(); const scheduleActions = jest.fn(); const getState = jest.fn(); @@ -87,7 +86,6 @@ describe('CpuUsageAlert', () => { callCluster: jest.fn(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { - hasScheduledActions, replaceState, scheduleActions, getState, @@ -154,7 +152,7 @@ describe('CpuUsageAlert', () => { endToken: '#end_link', type: 'docLink', partialUrl: - '{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/cluster-nodes-hot-threads.html', + '{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/cluster-nodes-hot-threads.html', }, ], }, @@ -166,7 +164,7 @@ describe('CpuUsageAlert', () => { endToken: '#end_link', type: 'docLink', partialUrl: - '{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/tasks.html', + '{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/tasks.html', }, ], }, @@ -506,7 +504,7 @@ describe('CpuUsageAlert', () => { endToken: '#end_link', type: 'docLink', partialUrl: - '{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/cluster-nodes-hot-threads.html', + '{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/cluster-nodes-hot-threads.html', }, ], }, @@ -518,7 +516,7 @@ describe('CpuUsageAlert', () => { endToken: '#end_link', type: 'docLink', partialUrl: - '{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/tasks.html', + '{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/tasks.html', }, ], }, diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts index ca9674c57216b..3117a160ecb62 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts @@ -193,13 +193,13 @@ export class CpuUsageAlert extends BaseAlert { i18n.translate('xpack.monitoring.alerts.cpuUsage.ui.nextSteps.hotThreads', { defaultMessage: '#start_linkCheck hot threads#end_link', }), - `{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/cluster-nodes-hot-threads.html` + `{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/cluster-nodes-hot-threads.html` ), createLink( i18n.translate('xpack.monitoring.alerts.cpuUsage.ui.nextSteps.runningTasks', { defaultMessage: '#start_linkCheck long running tasks#end_link', }), - `{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/tasks.html` + `{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/tasks.html` ), ], tokens: [ diff --git a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts index 546399f666b6c..e3d69820ebb05 100644 --- a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.test.ts @@ -89,7 +89,6 @@ describe('DiskUsageAlert', () => { }; const kibanaUrl = 'http://localhost:5601'; - const hasScheduledActions = jest.fn(); const replaceState = jest.fn(); const scheduleActions = jest.fn(); const getState = jest.fn(); @@ -98,7 +97,6 @@ describe('DiskUsageAlert', () => { callCluster: jest.fn(), alertInstanceFactory: jest.fn().mockImplementation(() => { return { - hasScheduledActions, replaceState, scheduleActions, getState, diff --git a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts index e43dca3ce87b1..c577550de8617 100644 --- a/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/disk_usage_alert.ts @@ -109,13 +109,13 @@ export class DiskUsageAlert extends BaseAlert { protected filterAlertInstance(alertInstance: RawAlertInstance, filters: CommonAlertFilter[]) { const alertInstanceStates = alertInstance.state?.alertStates as AlertDiskUsageState[]; - const nodeUuid = filters?.find((filter) => filter.nodeUuid); + const nodeFilter = filters?.find((filter) => filter.nodeUuid); - if (!filters || !filters.length || !alertInstanceStates?.length || !nodeUuid) { + if (!filters || !filters.length || !alertInstanceStates?.length || !nodeFilter?.nodeUuid) { return true; } - const nodeAlerts = alertInstanceStates.filter(({ nodeId }) => nodeId === nodeUuid); + const nodeAlerts = alertInstanceStates.filter(({ nodeId }) => nodeId === nodeFilter.nodeUuid); return Boolean(nodeAlerts.length); } @@ -160,7 +160,7 @@ export class DiskUsageAlert extends BaseAlert { i18n.translate('xpack.monitoring.alerts.diskUsage.ui.nextSteps.tuneDisk', { defaultMessage: '#start_linkTune for disk usage#end_link', }), - `{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/tune-for-disk-usage.html` + `{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/tune-for-disk-usage.html` ), createLink( i18n.translate('xpack.monitoring.alerts.diskUsage.ui.nextSteps.identifyIndices', { @@ -173,19 +173,19 @@ export class DiskUsageAlert extends BaseAlert { i18n.translate('xpack.monitoring.alerts.diskUsage.ui.nextSteps.ilmPolicies', { defaultMessage: '#start_linkImplement ILM policies#end_link', }), - `{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/index-lifecycle-management.html` + `{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/index-lifecycle-management.html` ), createLink( i18n.translate('xpack.monitoring.alerts.diskUsage.ui.nextSteps.addMoreNodes', { defaultMessage: '#start_linkAdd more data nodes#end_link', }), - `{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/add-elasticsearch-nodes.html` + `{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/add-elasticsearch-nodes.html` ), createLink( i18n.translate('xpack.monitoring.alerts.diskUsage.ui.nextSteps.resizeYourDeployment', { defaultMessage: '#start_linkResize your deployment (ECE)#end_link', }), - `{elasticWebsiteUrl}/guide/en/cloud-enterprise/current/ece-resize-deployment.html` + `{elasticWebsiteUrl}guide/en/cloud-enterprise/current/ece-resize-deployment.html` ), ], tokens: [ @@ -331,7 +331,7 @@ export class DiskUsageAlert extends BaseAlert { const alertInstanceState = { alertStates: newAlertStates }; instance.replaceState(alertInstanceState); - if (newAlertStates.length && !instance.hasScheduledActions()) { + if (newAlertStates.length) { this.executeActions(instance, alertInstanceState, null, cluster); state.lastExecutedAction = currentUTC; } diff --git a/x-pack/plugins/monitoring/server/alerts/index.ts b/x-pack/plugins/monitoring/server/alerts/index.ts index 41f6daa38d1dc..48254f2dec326 100644 --- a/x-pack/plugins/monitoring/server/alerts/index.ts +++ b/x-pack/plugins/monitoring/server/alerts/index.ts @@ -8,6 +8,7 @@ export { BaseAlert } from './base_alert'; export { CpuUsageAlert } from './cpu_usage_alert'; export { MissingMonitoringDataAlert } from './missing_monitoring_data_alert'; export { DiskUsageAlert } from './disk_usage_alert'; +export { MemoryUsageAlert } from './memory_usage_alert'; export { ClusterHealthAlert } from './cluster_health_alert'; export { LicenseExpirationAlert } from './license_expiration_alert'; export { NodesChangedAlert } from './nodes_changed_alert'; diff --git a/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts new file mode 100644 index 0000000000000..8dc707afab1e1 --- /dev/null +++ b/x-pack/plugins/monitoring/server/alerts/memory_usage_alert.ts @@ -0,0 +1,355 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IUiSettingsClient, Logger } from 'kibana/server'; +import { i18n } from '@kbn/i18n'; +import { BaseAlert } from './base_alert'; +import { + AlertData, + AlertCluster, + AlertState, + AlertMessage, + AlertMemoryUsageState, + AlertMessageTimeToken, + AlertMessageLinkToken, + AlertInstanceState, +} from './types'; +import { AlertInstance, AlertServices } from '../../../alerts/server'; +import { INDEX_PATTERN_ELASTICSEARCH, ALERT_MEMORY_USAGE } from '../../common/constants'; +import { fetchMemoryUsageNodeStats } from '../lib/alerts/fetch_memory_usage_node_stats'; +import { getCcsIndexPattern } from '../lib/alerts/get_ccs_index_pattern'; +import { AlertMessageTokenType, AlertSeverity, AlertParamType } from '../../common/enums'; +import { RawAlertInstance } from '../../../alerts/common'; +import { CommonAlertFilter, CommonAlertParams, CommonAlertParamDetail } from '../../common/types'; +import { AlertingDefaults, createLink } from './alerts_common'; +import { appendMetricbeatIndex } from '../lib/alerts/append_mb_index'; +import { parseDuration } from '../../../alerts/common/parse_duration'; + +interface ParamDetails { + [key: string]: CommonAlertParamDetail; +} + +export class MemoryUsageAlert extends BaseAlert { + public static readonly PARAM_DETAILS: ParamDetails = { + threshold: { + label: i18n.translate('xpack.monitoring.alerts.memoryUsage.paramDetails.threshold.label', { + defaultMessage: `Notify when memory usage is over`, + }), + type: AlertParamType.Percentage, + }, + duration: { + label: i18n.translate('xpack.monitoring.alerts.memoryUsage.paramDetails.duration.label', { + defaultMessage: `Look at the average over`, + }), + type: AlertParamType.Duration, + }, + }; + public static paramDetails = MemoryUsageAlert.PARAM_DETAILS; + public static readonly TYPE = ALERT_MEMORY_USAGE; + public static readonly LABEL = i18n.translate('xpack.monitoring.alerts.memoryUsage.label', { + defaultMessage: 'Memory Usage (JVM)', + }); + public type = MemoryUsageAlert.TYPE; + public label = MemoryUsageAlert.LABEL; + + protected defaultParams = { + threshold: 85, + duration: '5m', + }; + + protected actionVariables = [ + { + name: 'nodes', + description: i18n.translate('xpack.monitoring.alerts.memoryUsage.actionVariables.nodes', { + defaultMessage: 'The list of nodes reporting high memory usage.', + }), + }, + { + name: 'count', + description: i18n.translate('xpack.monitoring.alerts.memoryUsage.actionVariables.count', { + defaultMessage: 'The number of nodes reporting high memory usage.', + }), + }, + ...Object.values(AlertingDefaults.ALERT_TYPE.context), + ]; + + protected async fetchData( + params: CommonAlertParams, + callCluster: any, + clusters: AlertCluster[], + uiSettings: IUiSettingsClient, + availableCcs: string[] + ): Promise { + let esIndexPattern = appendMetricbeatIndex(this.config, INDEX_PATTERN_ELASTICSEARCH); + if (availableCcs) { + esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs); + } + const { duration, threshold } = params; + const parsedDuration = parseDuration(duration as string); + const endMs = +new Date(); + const startMs = endMs - parsedDuration; + + const stats = await fetchMemoryUsageNodeStats( + callCluster, + clusters, + esIndexPattern, + startMs, + endMs, + this.config.ui.max_bucket_size + ); + + return stats.map((stat) => { + const { clusterUuid, nodeId, memoryUsage, ccs } = stat; + return { + instanceKey: `${clusterUuid}:${nodeId}`, + shouldFire: memoryUsage > threshold, + severity: AlertSeverity.Danger, + meta: stat, + clusterUuid, + ccs, + }; + }); + } + + protected filterAlertInstance(alertInstance: RawAlertInstance, filters: CommonAlertFilter[]) { + const alertInstanceStates = alertInstance.state?.alertStates as AlertMemoryUsageState[]; + const nodeFilter = filters?.find((filter) => filter.nodeUuid); + + if (!filters || !filters.length || !alertInstanceStates?.length || !nodeFilter?.nodeUuid) { + return true; + } + + const nodeAlerts = alertInstanceStates.filter(({ nodeId }) => nodeId === nodeFilter.nodeUuid); + return Boolean(nodeAlerts.length); + } + + protected getDefaultAlertState(cluster: AlertCluster, item: AlertData): AlertState { + const currentState = super.getDefaultAlertState(cluster, item); + currentState.ui.severity = AlertSeverity.Warning; + return currentState; + } + + protected getUiMessage(alertState: AlertState, item: AlertData): AlertMessage { + const stat = item.meta as AlertMemoryUsageState; + if (!alertState.ui.isFiring) { + return { + text: i18n.translate('xpack.monitoring.alerts.memoryUsage.ui.resolvedMessage', { + defaultMessage: `The JVM memory usage on node {nodeName} is now under the threshold, currently reporting at {memoryUsage}% as of #resolved`, + values: { + nodeName: stat.nodeName, + memoryUsage: stat.memoryUsage.toFixed(2), + }, + }), + tokens: [ + { + startToken: '#resolved', + type: AlertMessageTokenType.Time, + isAbsolute: true, + isRelative: false, + timestamp: alertState.ui.resolvedMS, + } as AlertMessageTimeToken, + ], + }; + } + return { + text: i18n.translate('xpack.monitoring.alerts.memoryUsage.ui.firingMessage', { + defaultMessage: `Node #start_link{nodeName}#end_link is reporting JVM memory usage of {memoryUsage}% at #absolute`, + values: { + nodeName: stat.nodeName, + memoryUsage: stat.memoryUsage, + }, + }), + nextSteps: [ + createLink( + i18n.translate('xpack.monitoring.alerts.memoryUsage.ui.nextSteps.tuneThreadPools', { + defaultMessage: '#start_linkTune thread pools#end_link', + }), + `{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/modules-threadpool.html` + ), + createLink( + i18n.translate('xpack.monitoring.alerts.memoryUsage.ui.nextSteps.managingHeap', { + defaultMessage: '#start_linkManaging ES Heap#end_link', + }), + `{elasticWebsiteUrl}blog/a-heap-of-trouble` + ), + createLink( + i18n.translate('xpack.monitoring.alerts.memoryUsage.ui.nextSteps.identifyIndicesShards', { + defaultMessage: '#start_linkIdentify large indices/shards#end_link', + }), + 'elasticsearch/indices', + AlertMessageTokenType.Link + ), + createLink( + i18n.translate('xpack.monitoring.alerts.memoryUsage.ui.nextSteps.addMoreNodes', { + defaultMessage: '#start_linkAdd more data nodes#end_link', + }), + `{elasticWebsiteUrl}guide/en/elasticsearch/reference/{docLinkVersion}/add-elasticsearch-nodes.html` + ), + createLink( + i18n.translate('xpack.monitoring.alerts.memoryUsage.ui.nextSteps.resizeYourDeployment', { + defaultMessage: '#start_linkResize your deployment (ECE)#end_link', + }), + `{elasticWebsiteUrl}guide/en/cloud-enterprise/current/ece-resize-deployment.html` + ), + ], + tokens: [ + { + startToken: '#absolute', + type: AlertMessageTokenType.Time, + isAbsolute: true, + isRelative: false, + timestamp: alertState.ui.triggeredMS, + } as AlertMessageTimeToken, + { + startToken: '#start_link', + endToken: '#end_link', + type: AlertMessageTokenType.Link, + url: `elasticsearch/nodes/${stat.nodeId}`, + } as AlertMessageLinkToken, + ], + }; + } + + protected executeActions( + instance: AlertInstance, + { alertStates }: AlertInstanceState, + item: AlertData | null, + cluster: AlertCluster + ) { + const firingNodes = alertStates.filter( + (alertState) => alertState.ui.isFiring + ) as AlertMemoryUsageState[]; + const firingCount = firingNodes.length; + + if (firingCount > 0) { + const shortActionText = i18n.translate('xpack.monitoring.alerts.memoryUsage.shortAction', { + defaultMessage: 'Verify memory usage levels across affected nodes.', + }); + const fullActionText = i18n.translate('xpack.monitoring.alerts.memoryUsage.fullAction', { + defaultMessage: 'View nodes', + }); + + const ccs = alertStates.find((state) => state.ccs)?.ccs; + const globalStateLink = this.createGlobalStateLink( + 'elasticsearch/nodes', + cluster.clusterUuid, + ccs + ); + const action = `[${fullActionText}](${globalStateLink})`; + const internalShortMessage = i18n.translate( + 'xpack.monitoring.alerts.memoryUsage.firing.internalShortMessage', + { + defaultMessage: `Memory usage alert is firing for {count} node(s) in cluster: {clusterName}. {shortActionText}`, + values: { + count: firingCount, + clusterName: cluster.clusterName, + shortActionText, + }, + } + ); + const internalFullMessage = i18n.translate( + 'xpack.monitoring.alerts.memoryUsage.firing.internalFullMessage', + { + defaultMessage: `Memory usage alert is firing for {count} node(s) in cluster: {clusterName}. {action}`, + values: { + count: firingCount, + clusterName: cluster.clusterName, + action, + }, + } + ); + + instance.scheduleActions('default', { + internalShortMessage, + internalFullMessage: this.isCloud ? internalShortMessage : internalFullMessage, + state: AlertingDefaults.ALERT_STATE.firing, + nodes: firingNodes + .map((state) => `${state.nodeName}:${state.memoryUsage.toFixed(2)}`) + .join(','), + count: firingCount, + clusterName: cluster.clusterName, + action, + actionPlain: shortActionText, + }); + } else { + const resolvedNodes = (alertStates as AlertMemoryUsageState[]) + .filter((state) => !state.ui.isFiring) + .map((state) => `${state.nodeName}:${state.memoryUsage.toFixed(2)}`); + const resolvedCount = resolvedNodes.length; + + if (resolvedCount > 0) { + const internalMessage = i18n.translate( + 'xpack.monitoring.alerts.memoryUsage.resolved.internalMessage', + { + defaultMessage: `Memory usage alert is resolved for {count} node(s) in cluster: {clusterName}.`, + values: { + count: resolvedCount, + clusterName: cluster.clusterName, + }, + } + ); + + instance.scheduleActions('default', { + internalShortMessage: internalMessage, + internalFullMessage: internalMessage, + state: AlertingDefaults.ALERT_STATE.resolved, + nodes: resolvedNodes.join(','), + count: resolvedCount, + clusterName: cluster.clusterName, + }); + } + } + } + + protected async processData( + data: AlertData[], + clusters: AlertCluster[], + services: AlertServices, + logger: Logger, + state: any + ) { + const currentUTC = +new Date(); + for (const cluster of clusters) { + const nodes = data.filter((node) => node.clusterUuid === cluster.clusterUuid); + if (!nodes.length) { + continue; + } + + const firingNodeUuids = nodes + .filter((node) => node.shouldFire) + .map((node) => node.meta.nodeId) + .join(','); + const instanceId = `${this.type}:${cluster.clusterUuid}:${firingNodeUuids}`; + const instance = services.alertInstanceFactory(instanceId); + const newAlertStates: AlertMemoryUsageState[] = []; + + for (const node of nodes) { + const stat = node.meta as AlertMemoryUsageState; + const nodeState = this.getDefaultAlertState(cluster, node) as AlertMemoryUsageState; + nodeState.memoryUsage = stat.memoryUsage; + nodeState.nodeId = stat.nodeId; + nodeState.nodeName = stat.nodeName; + + if (node.shouldFire) { + nodeState.ui.triggeredMS = currentUTC; + nodeState.ui.isFiring = true; + nodeState.ui.severity = node.severity; + newAlertStates.push(nodeState); + } + nodeState.ui.message = this.getUiMessage(nodeState, node); + } + + const alertInstanceState = { alertStates: newAlertStates }; + instance.replaceState(alertInstanceState); + if (newAlertStates.length) { + this.executeActions(instance, alertInstanceState, null, cluster); + state.lastExecutedAction = currentUTC; + } + } + + state.lastChecked = currentUTC; + return state; + } +} diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts index 4c06d9718c455..6ed237a055b5c 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts @@ -234,9 +234,9 @@ describe('MissingMonitoringDataAlert', () => { ], }); expect(scheduleActions).toHaveBeenCalledWith('default', { - internalFullMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. [View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#overview?_g=(cluster_uuid:abc123))`, + internalFullMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. [View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123))`, internalShortMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, - action: `[View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#overview?_g=(cluster_uuid:abc123))`, + action: `[View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123))`, actionPlain: 'Verify these stack products are up and running, then double check the monitoring settings.', clusterName, @@ -414,9 +414,9 @@ describe('MissingMonitoringDataAlert', () => { } as any); const count = 1; expect(scheduleActions).toHaveBeenCalledWith('default', { - internalFullMessage: `We have not detected any monitoring data for 1 stack product(s) in cluster: testCluster. [View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#overview?_g=(cluster_uuid:abc123,ccs:testCluster))`, + internalFullMessage: `We have not detected any monitoring data for 1 stack product(s) in cluster: testCluster. [View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123,ccs:testCluster))`, internalShortMessage: `We have not detected any monitoring data for 1 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, - action: `[View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#overview?_g=(cluster_uuid:abc123,ccs:testCluster))`, + action: `[View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123,ccs:testCluster))`, actionPlain: 'Verify these stack products are up and running, then double check the monitoring settings.', clusterName, @@ -446,7 +446,7 @@ describe('MissingMonitoringDataAlert', () => { expect(scheduleActions).toHaveBeenCalledWith('default', { internalFullMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, internalShortMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, - action: `[View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#overview?_g=(cluster_uuid:abc123))`, + action: `[View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123))`, actionPlain: 'Verify these stack products are up and running, then double check the monitoring settings.', clusterName, diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts index 6017314f332e6..75dee475e7525 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.ts @@ -309,13 +309,6 @@ export class MissingMonitoringDataAlert extends BaseAlert { return; } - const ccs = instanceState.alertStates.reduce((accum: string, state): string => { - if (state.ccs) { - return state.ccs; - } - return accum; - }, ''); - const firingCount = instanceState.alertStates.filter((alertState) => alertState.ui.isFiring) .length; const firingStackProducts = instanceState.alertStates @@ -336,12 +329,10 @@ export class MissingMonitoringDataAlert extends BaseAlert { const fullActionText = i18n.translate('xpack.monitoring.alerts.missingData.fullAction', { defaultMessage: 'View what monitoring data we do have for these stack products.', }); - const globalState = [`cluster_uuid:${cluster.clusterUuid}`]; - if (ccs) { - globalState.push(`ccs:${ccs}`); - } - const url = `${this.kibanaUrl}/app/monitoring#overview?_g=(${globalState.join(',')})`; - const action = `[${fullActionText}](${url})`; + + const ccs = instanceState.alertStates.find((state) => state.ccs)?.ccs; + const globalStateLink = this.createGlobalStateLink('overview', cluster.clusterUuid, ccs); + const action = `[${fullActionText}](${globalStateLink})`; const internalShortMessage = i18n.translate( 'xpack.monitoring.alerts.missingData.firing.internalShortMessage', { diff --git a/x-pack/plugins/monitoring/server/alerts/types.d.ts b/x-pack/plugins/monitoring/server/alerts/types.d.ts index 4b78bca9f47ca..0b346e770a299 100644 --- a/x-pack/plugins/monitoring/server/alerts/types.d.ts +++ b/x-pack/plugins/monitoring/server/alerts/types.d.ts @@ -22,10 +22,17 @@ export interface AlertState { ui: AlertUiState; } -export interface AlertCpuUsageState extends AlertState { - cpuUsage: number; +export interface AlertNodeState extends AlertState { nodeId: string; - nodeName: string; + nodeName?: string; +} + +export interface AlertCpuUsageState extends AlertNodeState { + cpuUsage: number; +} + +export interface AlertDiskUsageState extends AlertNodeState { + diskUsage: number; } export interface AlertMissingDataState extends AlertState { @@ -35,10 +42,8 @@ export interface AlertMissingDataState extends AlertState { gapDuration: number; } -export interface AlertDiskUsageState extends AlertState { - diskUsage: number; - nodeId: string; - nodeName?: string; +export interface AlertMemoryUsageState extends AlertNodeState { + memoryUsage: number; } export interface AlertUiState { @@ -81,23 +86,26 @@ export interface AlertCluster { clusterName: string; } -export interface AlertCpuUsageNodeStats { +export interface AlertNodeStats { clusterUuid: string; nodeId: string; - nodeName: string; + nodeName?: string; + ccs?: string; +} + +export interface AlertCpuUsageNodeStats extends AlertNodeStats { cpuUsage: number; containerUsage: number; containerPeriods: number; containerQuota: number; - ccs?: string; } -export interface AlertDiskUsageNodeStats { - clusterUuid: string; - nodeId: string; - nodeName: string; +export interface AlertDiskUsageNodeStats extends AlertNodeStats { diskUsage: number; - ccs?: string; +} + +export interface AlertMemoryUsageNodeStats extends AlertNodeStats { + memoryUsage: number; } export interface AlertMissingData { diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts new file mode 100644 index 0000000000000..c6843c3ed5f12 --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { AlertCluster, AlertMemoryUsageNodeStats } from '../../alerts/types'; + +export async function fetchMemoryUsageNodeStats( + callCluster: any, + clusters: AlertCluster[], + index: string, + startMs: number, + endMs: number, + size: number +): Promise { + const clustersIds = clusters.map((cluster) => cluster.clusterUuid); + const params = { + index, + filterPath: ['aggregations'], + body: { + size: 0, + query: { + bool: { + filter: [ + { + terms: { + cluster_uuid: clustersIds, + }, + }, + { + term: { + type: 'node_stats', + }, + }, + { + range: { + timestamp: { + format: 'epoch_millis', + gte: startMs, + lte: endMs, + }, + }, + }, + ], + }, + }, + aggs: { + clusters: { + terms: { + field: 'cluster_uuid', + size, + }, + aggs: { + nodes: { + terms: { + field: 'source_node.uuid', + size, + }, + aggs: { + index: { + terms: { + field: '_index', + size: 1, + }, + }, + avg_heap: { + avg: { + field: 'node_stats.jvm.mem.heap_used_percent', + }, + }, + cluster_uuid: { + terms: { + field: 'cluster_uuid', + size: 1, + }, + }, + name: { + terms: { + field: 'source_node.name', + size: 1, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const response = await callCluster('search', params); + const stats: AlertMemoryUsageNodeStats[] = []; + const { buckets: clusterBuckets = [] } = response.aggregations.clusters; + + if (!clusterBuckets.length) { + return stats; + } + + for (const clusterBucket of clusterBuckets) { + for (const node of clusterBucket.nodes.buckets) { + const indexName = get(node, 'index.buckets[0].key', ''); + const memoryUsage = Math.floor(Number(get(node, 'avg_heap.value'))); + if (isNaN(memoryUsage) || memoryUsage === undefined || memoryUsage === null) { + continue; + } + stats.push({ + memoryUsage, + clusterUuid: clusterBucket.key, + nodeId: node.key, + nodeName: get(node, 'name.buckets[0].key'), + ccs: indexName.includes(':') ? indexName.split(':')[0] : null, + }); + } + } + return stats; +} diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index 1304936860b77..85489525cc306 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -20,6 +20,7 @@ describe('renderApp', () => { chrome: { docTitle: { change: () => {} }, setBreadcrumbs: () => {} }, i18n: { Context: ({ children }: { children: React.ReactNode }) => children }, uiSettings: { get: () => false }, + http: { basePath: { prepend: (path: string) => path } }, } as unknown) as CoreStart; const params = ({ element: window.document.createElement('div'), diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 5c23c7a065b5e..879d745ff2b64 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -123,7 +123,7 @@ export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props) defaultMessage: 'Down', })} series={series?.down} - ticktFormatter={formatter} + tickFormatter={formatter} color={downColor} /> @@ -145,13 +145,13 @@ function UptimeBarSeries({ label, series, color, - ticktFormatter, + tickFormatter, }: { id: string; label: string; series?: Series; color: string; - ticktFormatter: TickFormatter; + tickFormatter: TickFormatter; }) { if (!series) { return null; @@ -178,7 +178,7 @@ function UptimeBarSeries({ position={Position.Bottom} showOverlappingTicks={false} showOverlappingLabels={false} - tickFormat={ticktFormatter} + tickFormat={tickFormatter} /> { + it('renders with core web vitals', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: response, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + const { getByText, getAllByText } = render( + + ); + + expect(getByText('User Experience')).toBeInTheDocument(); + expect(getByText('View in app')).toBeInTheDocument(); + expect(getByText('elastic-co-frontend')).toBeInTheDocument(); + expect(getByText('Largest contentful paint')).toBeInTheDocument(); + expect(getByText('Largest contentful paint 1.94 s')).toBeInTheDocument(); + expect(getByText('First input delay 14 ms')).toBeInTheDocument(); + expect(getByText('Cumulative layout shift 0.01')).toBeInTheDocument(); + + expect(getByText('Largest contentful paint')).toBeInTheDocument(); + expect(getByText('Largest contentful paint 1.94 s')).toBeInTheDocument(); + expect(getByText('First input delay 14 ms')).toBeInTheDocument(); + expect(getByText('Cumulative layout shift 0.01')).toBeInTheDocument(); + + // LCP Rank Values + expect(getByText('Good (65%)')).toBeInTheDocument(); + expect(getByText('Needs improvement (19%)')).toBeInTheDocument(); + + // LCP and FID both have same poor value + expect(getAllByText('Poor (16%)')).toHaveLength(2); + + // FID Rank Values + expect(getByText('Good (73%)')).toBeInTheDocument(); + expect(getByText('Needs improvement (11%)')).toBeInTheDocument(); + + // CLS Rank Values + expect(getByText('Good (86%)')).toBeInTheDocument(); + expect(getByText('Needs improvement (8%)')).toBeInTheDocument(); + expect(getByText('Poor (6%)')).toBeInTheDocument(); + }); + it('shows loading state', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: undefined, + status: fetcherHook.FETCH_STATUS.LOADING, + refetch: jest.fn(), + }); + const { getByText, queryAllByText, getAllByText } = render( + + ); + + expect(getByText('User Experience')).toBeInTheDocument(); + expect(getAllByText('Statistic is loading')).toHaveLength(3); + expect(queryAllByText('View in app')).toEqual([]); + expect(getByText('elastic-co-frontend')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx new file mode 100644 index 0000000000000..0c40ce0bf7a2e --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { SectionContainer } from '../'; +import { getDataHandler } from '../../../../data_handler'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { CoreVitals } from '../../../shared/core_web_vitals'; + +interface Props { + serviceName: string; + bucketSize: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; +} + +export function UXSection({ serviceName, bucketSize, absoluteTime, relativeTime }: Props) { + const { start, end } = absoluteTime; + + const { data, status } = useFetcher(() => { + if (start && end) { + return getDataHandler('ux')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + serviceName, + bucketSize, + }); + } + }, [start, end, relativeTime, serviceName, bucketSize]); + + const isLoading = status === FETCH_STATUS.LOADING; + + const { appLink, coreWebVitals } = data || {}; + + return ( + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/app/section/ux/mock_data/ux.mock.ts b/x-pack/plugins/observability/public/components/app/section/ux/mock_data/ux.mock.ts new file mode 100644 index 0000000000000..e61564f9df753 --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/section/ux/mock_data/ux.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UxFetchDataResponse } from '../../../../../typings'; + +export const response: UxFetchDataResponse = { + appLink: '/app/ux', + coreWebVitals: { + cls: '0.01', + fid: 13.5, + lcp: 1942.6666666666667, + tbt: 281.55833333333334, + fcp: 1487, + lcpRanks: [65, 19, 16], + fidRanks: [73, 11, 16], + clsRanks: [86, 8, 6], + }, +}; diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx new file mode 100644 index 0000000000000..39be850e5a93b --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ComponentType } from 'react'; +import { IntlProvider } from 'react-intl'; +import { Observable } from 'rxjs'; +import { CoreStart } from 'src/core/public'; +import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public'; +import { CoreVitalItem } from '../core_vital_item'; +import { LCP_LABEL } from '../translations'; +import { EuiThemeProvider } from '../../../../typings'; + +const KibanaReactContext = createKibanaReactContext(({ + uiSettings: { get: () => {}, get$: () => new Observable() }, +} as unknown) as Partial); + +export default { + title: 'app/RumDashboard/CoreVitalItem', + component: CoreVitalItem, + decorators: [ + (Story: ComponentType) => ( + + + + + + + + ), + ], +}; + +export function Basic() { + return ( + + ); +} + +export function FiftyPercentGood() { + return ( + + ); +} + +export function OneHundredPercentBad() { + return ( + + ); +} + +export function OneHundredPercentAverage() { + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/color_palette_flex_item.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx rename to x-pack/plugins/observability/public/components/shared/core_web_vitals/color_palette_flex_item.tsx index fc2390acde0be..4b5f3ee80f7bf 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/ColorPaletteFlexItem.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/color_palette_flex_item.tsx @@ -14,12 +14,7 @@ const ColoredSpan = styled.div` cursor: pointer; `; -const getSpanStyle = ( - position: number, - inFocus: boolean, - hexCode: string, - percentage: number -) => { +const getSpanStyle = (position: number, inFocus: boolean, hexCode: string, percentage: number) => { let first = position === 0 || percentage === 100; let last = position === 2 || percentage === 100; if (percentage === 100) { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx similarity index 80% rename from x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx rename to x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx index 6107a8e764adb..4c84a163d3324 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/CoreVitalItem.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx @@ -4,16 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - euiPaletteForStatus, - EuiSpacer, - EuiStat, -} from '@elastic/eui'; +import { EuiFlexGroup, euiPaletteForStatus, EuiSpacer, EuiStat } from '@elastic/eui'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { PaletteLegends } from './PaletteLegends'; -import { ColorPaletteFlexItem } from './ColorPaletteFlexItem'; +import { PaletteLegends } from './palette_legends'; +import { ColorPaletteFlexItem } from './color_palette_flex_item'; import { CV_AVERAGE_LABEL, CV_GOOD_LABEL, @@ -45,7 +40,7 @@ export function getCoreVitalTooltipMessage( const bad = position === 2; const average = !good && !bad; - return i18n.translate('xpack.apm.csm.dashboard.webVitals.palette.tooltip', { + return i18n.translate('xpack.observability.ux.dashboard.webVitals.palette.tooltip', { defaultMessage: '{percentage} % of users have {exp} experience because the {title} takes {moreOrLess} than {value}{averageMessage}.', values: { @@ -55,7 +50,7 @@ export function getCoreVitalTooltipMessage( moreOrLess: bad || average ? MORE_LABEL : LESS_LABEL, value: good || average ? thresholds.good : thresholds.bad, averageMessage: average - ? i18n.translate('xpack.apm.rum.coreVitals.averageMessage', { + ? i18n.translate('xpack.observability.ux.coreVitals.averageMessage', { defaultMessage: ' and less than {bad}', values: { bad: thresholds.bad }, }) @@ -64,13 +59,7 @@ export function getCoreVitalTooltipMessage( }); } -export function CoreVitalItem({ - loading, - title, - value, - thresholds, - ranks = [100, 0, 0], -}: Props) { +export function CoreVitalItem({ loading, title, value, thresholds, ranks = [100, 0, 0] }: Props) { const palette = euiPaletteForStatus(3); const [inFocusInd, setInFocusInd] = useState(null); @@ -100,12 +89,7 @@ export function CoreVitalItem({ position={ind} inFocus={inFocusInd !== ind && inFocusInd !== null} percentage={ranks[ind]} - tooltip={getCoreVitalTooltipMessage( - thresholds, - ind, - title, - ranks[ind] - )} + tooltip={getCoreVitalTooltipMessage(thresholds, ind, title, ranks[ind])} /> ))} diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/index.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/index.tsx new file mode 100644 index 0000000000000..6d44cd51285ba --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CLS_LABEL, FID_LABEL, LCP_LABEL } from './translations'; +import { CoreVitalItem } from './core_vital_item'; +import { WebCoreVitalsTitle } from './web_core_vitals_title'; +import { ServiceName } from './service_name'; + +export interface UXMetrics { + cls: string; + fid: number; + lcp: number; + tbt: number; + fcp: number; + lcpRanks: number[]; + fidRanks: number[]; + clsRanks: number[]; +} + +export function formatToSec(value?: number | string, fromUnit = 'MicroSec'): string { + const valueInMs = Number(value ?? 0) / (fromUnit === 'MicroSec' ? 1000 : 1); + + if (valueInMs < 1000) { + return valueInMs.toFixed(0) + ' ms'; + } + return (valueInMs / 1000).toFixed(2) + ' s'; +} + +const CoreVitalsThresholds = { + LCP: { good: '2.5s', bad: '4.0s' }, + FID: { good: '100ms', bad: '300ms' }, + CLS: { good: '0.1', bad: '0.25' }, +}; + +interface Props { + loading: boolean; + data?: UXMetrics | null; + displayServiceName?: boolean; + serviceName?: string; +} + +export function CoreVitals({ data, loading, displayServiceName, serviceName }: Props) { + const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {}; + + return ( + <> + + + {displayServiceName && } + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/palette_legends.tsx similarity index 75% rename from x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx rename to x-pack/plugins/observability/public/components/shared/core_web_vitals/palette_legends.tsx index d27581c97de23..682cf5aa6538b 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/PaletteLegends.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/palette_legends.tsx @@ -17,8 +17,8 @@ import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { getCoreVitalTooltipMessage, Thresholds } from './CoreVitalItem'; -import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; +import { getCoreVitalTooltipMessage, Thresholds } from './core_vital_item'; +import { useUiSetting$ } from '../../../../../../../src/plugins/kibana_react/public'; import { LEGEND_NEEDS_IMPROVEMENT_LABEL, LEGEND_GOOD_LABEL, @@ -37,9 +37,7 @@ const StyledSpan = styled.span<{ }>` &:hover { background-color: ${(props) => - props.darkMode - ? euiDarkVars.euiColorLightestShade - : euiLightVars.euiColorLightestShade}; + props.darkMode ? euiDarkVars.euiColorLightestShade : euiLightVars.euiColorLightestShade}; } `; @@ -50,20 +48,11 @@ interface Props { title: string; } -export function PaletteLegends({ - ranks, - title, - onItemHover, - thresholds, -}: Props) { +export function PaletteLegends({ ranks, title, onItemHover, thresholds }: Props) { const [darkMode] = useUiSetting$('theme:darkMode'); const palette = euiPaletteForStatus(3); - const labels = [ - LEGEND_GOOD_LABEL, - LEGEND_NEEDS_IMPROVEMENT_LABEL, - LEGEND_POOR_LABEL, - ]; + const labels = [LEGEND_GOOD_LABEL, LEGEND_NEEDS_IMPROVEMENT_LABEL, LEGEND_POOR_LABEL]; return ( @@ -79,19 +68,14 @@ export function PaletteLegends({ }} > + + {SERVICE_LABEL} + + + +

{name}

+
+ + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/translations.ts b/x-pack/plugins/observability/public/components/shared/core_web_vitals/translations.ts new file mode 100644 index 0000000000000..546d828f9dab0 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/translations.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const LCP_LABEL = i18n.translate('xpack.observability.ux.coreVitals.lcp', { + defaultMessage: 'Largest contentful paint', +}); + +export const FID_LABEL = i18n.translate('xpack.observability.ux.coreVitals.fip', { + defaultMessage: 'First input delay', +}); + +export const CLS_LABEL = i18n.translate('xpack.observability.ux.coreVitals.cls', { + defaultMessage: 'Cumulative layout shift', +}); + +export const CV_POOR_LABEL = i18n.translate('xpack.observability.ux.coreVitals.poor', { + defaultMessage: 'a poor', +}); + +export const CV_GOOD_LABEL = i18n.translate('xpack.observability.ux.coreVitals.good', { + defaultMessage: 'a good', +}); + +export const CV_AVERAGE_LABEL = i18n.translate('xpack.observability.ux.coreVitals.average', { + defaultMessage: 'an average', +}); + +export const LEGEND_POOR_LABEL = i18n.translate('xpack.observability.ux.coreVitals.legends.poor', { + defaultMessage: 'Poor', +}); + +export const LEGEND_GOOD_LABEL = i18n.translate('xpack.observability.ux.coreVitals.legends.good', { + defaultMessage: 'Good', +}); + +export const LEGEND_NEEDS_IMPROVEMENT_LABEL = i18n.translate( + 'xpack.observability.ux.coreVitals.legends.needsImprovement', + { + defaultMessage: 'Needs improvement', + } +); + +export const MORE_LABEL = i18n.translate('xpack.observability.ux.coreVitals.more', { + defaultMessage: 'more', +}); + +export const LESS_LABEL = i18n.translate('xpack.observability.ux.coreVitals.less', { + defaultMessage: 'less', +}); diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/web_core_vitals_title.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/web_core_vitals_title.tsx new file mode 100644 index 0000000000000..de3453c5c2c1b --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/web_core_vitals_title.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiButtonIcon, EuiLink, EuiPopover, EuiText, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +const CORE_WEB_VITALS = i18n.translate('xpack.observability.ux.coreWebVitals', { + defaultMessage: 'Core web vitals', +}); + +export function WebCoreVitalsTitle() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const closePopover = () => setIsPopoverOpen(false); + + return ( + +

+ {CORE_WEB_VITALS} + setIsPopoverOpen(true)} + color={'text'} + iconType={'questionInCircle'} + /> + } + closePopover={closePopover} + > +
+ + + + {' '} + {CORE_WEB_VITALS} + + +
+
+

+
+ ); +} diff --git a/x-pack/plugins/observability/public/context/plugin_context.tsx b/x-pack/plugins/observability/public/context/plugin_context.tsx index 7d705e7a6cc05..c7376fc34682d 100644 --- a/x-pack/plugins/observability/public/context/plugin_context.tsx +++ b/x-pack/plugins/observability/public/context/plugin_context.tsx @@ -5,10 +5,10 @@ */ import { createContext } from 'react'; -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; export interface PluginContextValue { - core: AppMountContext['core']; + core: CoreStart; } export const PluginContext = createContext({} as PluginContextValue); diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 935fc0682c414..dae2f62777d30 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -15,6 +15,7 @@ import { LogsFetchDataResponse, MetricsFetchDataResponse, UptimeFetchDataResponse, + UxFetchDataResponse, } from './typings'; const params = { @@ -273,6 +274,60 @@ describe('registerDataHandler', () => { expect(hasData).toBeTruthy(); }); }); + describe('Ux', () => { + registerDataHandler({ + appName: 'ux', + fetchData: async () => { + return { + title: 'User Experience', + appLink: '/ux', + coreWebVitals: { + cls: '0.01', + fid: 5, + lcp: 1464.3333333333333, + tbt: 232.92166666666665, + fcp: 1154.8, + lcpRanks: [73, 16, 11], + fidRanks: [85, 4, 11], + clsRanks: [88, 7, 5], + }, + }; + }, + hasData: async () => ({ hasData: true, serviceName: 'elastic-co-frontend' }), + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('ux'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('ux'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'User Experience', + appLink: '/ux', + coreWebVitals: { + cls: '0.01', + fid: 5, + lcp: 1464.3333333333333, + tbt: 232.92166666666665, + fcp: 1154.8, + lcpRanks: [73, 16, 11], + fidRanks: [85, 4, 11], + clsRanks: [88, 7, 5], + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('ux'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + describe('Metrics', () => { registerDataHandler({ appName: 'infra_metrics', @@ -396,6 +451,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -425,11 +481,19 @@ describe('registerDataHandler', () => { throw new Error('BOOM'); }, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => { + throw new Error('BOOM'); + }, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); it('returns true when has data and false when an exception happens', async () => { @@ -437,6 +501,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -462,11 +527,19 @@ describe('registerDataHandler', () => { throw new Error('BOOM'); }, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => { + throw new Error('BOOM'); + }, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: true, uptime: false, infra_logs: true, infra_metrics: false, + ux: false, }); }); it('returns true when has data', async () => { @@ -474,6 +547,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -495,11 +569,23 @@ describe('registerDataHandler', () => { fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), hasData: async () => true, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => ({ + hasData: true, + serviceName: 'elastic-co', + }), + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: true, uptime: true, infra_logs: true, infra_metrics: true, + ux: { + hasData: true, + serviceName: 'elastic-co', + }, }); }); it('returns false when has no data', async () => { @@ -507,6 +593,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -528,11 +615,17 @@ describe('registerDataHandler', () => { fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), hasData: async () => false, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => false, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); it('returns false when has data was not registered', async () => { @@ -540,12 +633,14 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); - expect(await fetchHasData()).toEqual({ + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); }); diff --git a/x-pack/plugins/observability/public/data_handler.ts b/x-pack/plugins/observability/public/data_handler.ts index cae21fd9fed52..91043a3da0dab 100644 --- a/x-pack/plugins/observability/public/data_handler.ts +++ b/x-pack/plugins/observability/public/data_handler.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DataHandler, ObservabilityFetchDataPlugins } from './typings/fetch_overview_data'; +import { + DataHandler, + HasDataResponse, + ObservabilityFetchDataPlugins, +} from './typings/fetch_overview_data'; const dataHandlers: Partial> = {}; @@ -31,14 +35,26 @@ export function getDataHandler(appName: } } -export async function fetchHasData(): Promise> { - const apps: ObservabilityFetchDataPlugins[] = ['apm', 'uptime', 'infra_logs', 'infra_metrics']; +export async function fetchHasData(absoluteTime: { + start: number; + end: number; +}): Promise> { + const apps: ObservabilityFetchDataPlugins[] = [ + 'apm', + 'uptime', + 'infra_logs', + 'infra_metrics', + 'ux', + ]; - const promises = apps.map(async (app) => getDataHandler(app)?.hasData() || false); + const promises = apps.map( + async (app) => + getDataHandler(app)?.hasData(app === 'ux' ? { absoluteTime } : undefined) || false + ); const results = await Promise.allSettled(promises); - const [apm, uptime, logs, metrics] = results.map((result) => { + const [apm, uptime, logs, metrics, ux] = results.map((result) => { if (result.status === 'fulfilled') { return result.value; } @@ -50,6 +66,7 @@ export async function fetchHasData(): Promise { + return search ? parse(search[0] === '?' ? search.slice(1) : search, { sort: false }) : {}; +}; + +export function useQueryParams() { + const { from, to } = useKibanaUISettings(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); + + const { rangeFrom, rangeTo } = getParsedParams(useLocation().search); + + return useMemo(() => { + return { + start: (rangeFrom as string) ?? from, + end: (rangeTo as string) ?? to, + absStart: getAbsoluteTime((rangeFrom as string) ?? from)!, + absEnd: getAbsoluteTime((rangeTo as string) ?? to, { roundUp: true })!, + }; + }, [rangeFrom, rangeTo, from, to]); +} diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 0aecea59ad013..9c16e3034400b 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -17,6 +17,8 @@ export const plugin: PluginInitializer fetchHasData(), []); + + const { absStart, absEnd } = useQueryParams(); + + const { data = {} } = useFetcher( + () => fetchHasData({ start: absStart, end: absEnd }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); const values = Object.values(data); const hasSomeData = values.length ? values.some((hasData) => hasData) : null; @@ -24,5 +33,5 @@ export function HomePage() { } }, [hasSomeData, history]); - return <>; + return ; } diff --git a/x-pack/plugins/observability/public/pages/overview/data_sections.tsx b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx new file mode 100644 index 0000000000000..dfb335902b7b8 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { LogsSection } from '../../components/app/section/logs'; +import { MetricsSection } from '../../components/app/section/metrics'; +import { APMSection } from '../../components/app/section/apm'; +import { UptimeSection } from '../../components/app/section/uptime'; +import { UXSection } from '../../components/app/section/ux'; +import { + HasDataResponse, + ObservabilityFetchDataPlugins, + UXHasDataResponse, +} from '../../typings/fetch_overview_data'; + +interface Props { + bucketSize: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; + hasData: Record; +} + +export function DataSections({ bucketSize, hasData, absoluteTime, relativeTime }: Props) { + return ( + + + {hasData?.infra_logs && ( + + + + )} + {hasData?.infra_metrics && ( + + + + )} + {hasData?.apm && ( + + + + )} + {hasData?.uptime && ( + + + + )} + {hasData?.ux && ( + + + + )} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 0330ba5cc04b4..5b13f2bcbbbd7 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -69,6 +69,21 @@ export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): I }), href: core.http.basePath.prepend('/app/home#/tutorial/uptimeMonitors'), }, + { + id: 'ux', + title: i18n.translate('xpack.observability.emptySection.apps.ux.title', { + defaultMessage: 'User Experience', + }), + icon: 'logoAPM', + description: i18n.translate('xpack.observability.emptySection.apps.ux.description', { + defaultMessage: + 'Performance is a distribution. Measure the experiences of all visitors to your web application and understand how to improve the experience for everyone.', + }), + linkTitle: i18n.translate('xpack.observability.emptySection.apps.ux.link', { + defaultMessage: 'Install Rum Agent', + }), + href: core.http.basePath.prepend('/app/home#/tutorial/apm'), + }, { id: 'alert', title: i18n.translate('xpack.observability.emptySection.apps.alert.title', { diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 3d10e4abcbb42..e52ca621fb8f8 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -8,43 +8,52 @@ import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; import { EmptySection } from '../../components/app/empty_section'; import { WithHeaderLayout } from '../../components/app/layout/with_header'; -import { NewsFeed } from '../../components/app/news_feed'; import { Resources } from '../../components/app/resources'; import { AlertsSection } from '../../components/app/section/alerts'; -import { APMSection } from '../../components/app/section/apm'; -import { LogsSection } from '../../components/app/section/logs'; -import { MetricsSection } from '../../components/app/section/metrics'; -import { UptimeSection } from '../../components/app/section/uptime'; import { DatePicker, TimePickerTime } from '../../components/shared/data_picker'; +import { NewsFeed } from '../../components/app/news_feed'; import { fetchHasData } from '../../data_handler'; import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher'; import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_settings'; import { usePluginContext } from '../../hooks/use_plugin_context'; -import { useTrackPageview } from '../../hooks/use_track_metric'; import { RouteParams } from '../../routes'; -import { getNewsFeed } from '../../services/get_news_feed'; import { getObservabilityAlerts } from '../../services/get_observability_alerts'; import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; +import { getNewsFeed } from '../../services/get_news_feed'; +import { DataSections } from './data_sections'; +import { useTrackPageview } from '../..'; interface Props { routeParams: RouteParams<'/overview'>; } -function calculatetBucketSize({ start, end }: { start?: number; end?: number }) { +function calculateBucketSize({ start, end }: { start?: number; end?: number }) { if (start && end) { return getBucketSize({ start, end, minInterval: '60s' }); } } export function OverviewPage({ routeParams }: Props) { - const { core } = usePluginContext(); + const timePickerTime = useKibanaUISettings(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); + + const relativeTime = { + start: routeParams.query.rangeFrom ?? timePickerTime.from, + end: routeParams.query.rangeTo ?? timePickerTime.to, + }; + + const absoluteTime = { + start: getAbsoluteTime(relativeTime.start) as number, + end: getAbsoluteTime(relativeTime.end, { roundUp: true }) as number, + }; useTrackPageview({ app: 'observability', path: 'overview' }); useTrackPageview({ app: 'observability', path: 'overview', delay: 15000 }); + const { core } = usePluginContext(); + const { data: alerts = [], status: alertStatus } = useFetcher(() => { return getObservabilityAlerts({ core }); }, [core]); @@ -52,9 +61,12 @@ export function OverviewPage({ routeParams }: Props) { const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]); const theme = useContext(ThemeContext); - const timePickerTime = useKibanaUISettings(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); - const result = useFetcher(() => fetchHasData(), []); + const result = useFetcher( + () => fetchHasData(absoluteTime), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); const hasData = result.data; if (!hasData) { @@ -63,17 +75,7 @@ export function OverviewPage({ routeParams }: Props) { const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; - const relativeTime = { - start: routeParams.query.rangeFrom ?? timePickerTime.from, - end: routeParams.query.rangeTo ?? timePickerTime.to, - }; - - const absoluteTime = { - start: getAbsoluteTime(relativeTime.start), - end: getAbsoluteTime(relativeTime.end, { roundUp: true }), - }; - - const bucketSize = calculatetBucketSize({ + const bucketSize = calculateBucketSize({ start: absoluteTime.start, end: absoluteTime.end, }); @@ -117,46 +119,12 @@ export function OverviewPage({ routeParams }: Props) { {/* Data sections */} {showDataSections && ( - - - {hasData.infra_logs && ( - - - - )} - {hasData.infra_metrics && ( - - - - )} - {hasData.apm && ( - - - - )} - {hasData.uptime && ( - - - - )} - - + )} {/* Empty sections */} diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index ff34116f59104..4645783af9fba 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -6,7 +6,7 @@ import { makeDecorator } from '@storybook/addons'; import { storiesOf } from '@storybook/react'; -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; @@ -36,7 +36,7 @@ const withCore = makeDecorator({ return ( - + {storyFn(context)} @@ -119,7 +119,7 @@ const core = ({ return euiSettings[key]; }, }, -} as unknown) as AppMountContext['core']; +} as unknown) as CoreStart; const coreWithAlerts = ({ ...core, @@ -127,7 +127,7 @@ const coreWithAlerts = ({ ...core.http, get: alertsFetchData, }, -} as unknown) as AppMountContext['core']; +} as unknown) as CoreStart; const coreWithNewsFeed = ({ ...core, @@ -135,7 +135,7 @@ const coreWithNewsFeed = ({ ...core.http, get: newsFeedFetchData, }, -} as unknown) as AppMountContext['core']; +} as unknown) as CoreStart; const coreAlertsThrowsError = ({ ...core, @@ -145,7 +145,7 @@ const coreAlertsThrowsError = ({ throw new Error('Error fetching Alerts data'); }, }, -} as unknown) as AppMountContext['core']; +} as unknown) as CoreStart; storiesOf('app/Overview', module) .addDecorator(withCore(core)) diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 41330e878035c..a64e6fc55b85a 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -5,6 +5,7 @@ */ import { ObservabilityApp } from '../../../typings/common'; +import { UXMetrics } from '../../components/shared/core_web_vitals'; export interface Stat { type: 'number' | 'percent' | 'bytesPerSecond'; @@ -24,17 +25,29 @@ export interface FetchDataParams { absoluteTime: { start: number; end: number }; relativeTime: { start: string; end: string }; bucketSize: string; + serviceName?: string; } +export interface HasDataParams { + absoluteTime: { start: number; end: number }; +} + +export interface UXHasDataResponse { + hasData: boolean; + serviceName: string | number | undefined; +} + +export type HasDataResponse = UXHasDataResponse | boolean; + export type FetchData = ( fetchDataParams: FetchDataParams ) => Promise; -export type HasData = () => Promise; +export type HasData = (params?: HasDataParams) => Promise; export type ObservabilityFetchDataPlugins = Exclude< ObservabilityApp, - 'observability' | 'stack_monitoring' | 'ux' + 'observability' | 'stack_monitoring' >; export interface DataHandler< @@ -89,9 +102,14 @@ export interface ApmFetchDataResponse extends FetchDataResponse { }; } +export interface UxFetchDataResponse extends FetchDataResponse { + coreWebVitals: UXMetrics; +} + export interface ObservabilityFetchDataResponse { apm: ApmFetchDataResponse; infra_metrics: MetricsFetchDataResponse; infra_logs: LogsFetchDataResponse; uptime: UptimeFetchDataResponse; + ux: UxFetchDataResponse; } diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index 2a290f2b24d6b..34a1bcad4ca96 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -5,9 +5,11 @@ */ import React from 'react'; import { render as testLibRender } from '@testing-library/react'; -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; +import { of } from 'rxjs'; import { PluginContext } from '../context/plugin_context'; import { EuiThemeProvider } from '../typings'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; export const core = ({ http: { @@ -15,12 +17,18 @@ export const core = ({ prepend: jest.fn(), }, }, -} as unknown) as AppMountContext['core']; + uiSettings: { + get: (key: string) => true, + get$: (key: string) => of(true), + }, +} as unknown) as CoreStart; export const render = (component: React.ReactNode) => { return testLibRender( - - {component} - + + + {component} + + ); }; diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx index 9eb2616cebb18..9a62187cffd33 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx @@ -536,7 +536,7 @@ export class EditUserPage extends Component { {isNewUser || showChangePasswordForm ? null : ( - + { toolsRight: this.renderToolsRight(), box: { incremental: true, + 'data-test-subj': 'searchUsers', }, onChange: (query: any) => { this.setState({ @@ -275,12 +276,18 @@ export class UsersGridPage extends Component { private handleDelete = (usernames: string[], errors: string[]) => { const { users } = this.state; + const filteredUsers = users.filter(({ username }) => { + return !usernames.includes(username) || errors.includes(username); + }); this.setState({ selection: [], showDeleteConfirmation: false, - users: users.filter(({ username }) => { - return !usernames.includes(username) || errors.includes(username); - }), + users: filteredUsers, + visibleUsers: this.getVisibleUsers( + filteredUsers, + this.state.filter, + this.state.includeReservedUsers + ), }); }; diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index fcc652505ba3a..4f52ebe3065a3 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -1374,6 +1374,14 @@ describe('Authenticator', () => { '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' ) ); + + // Unauthenticated session should be treated as non-existent one. + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' + ) + ); expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); }); }); @@ -1591,26 +1599,6 @@ describe('Authenticator', () => { ); }); - it('does not redirect to Overwritten Session if session was unauthenticated before this authentication attempt', async () => { - const request = httpServerMock.createKibanaRequest(); - mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); - - const newMockUser = mockAuthenticatedUser({ username: 'new-username' }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(newMockUser, { - state: 'some-state', - authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, - }) - ); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(newMockUser, { - state: 'some-state', - authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, - }) - ); - }); - it('redirects to Overwritten Session when username changes', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 1fb9d9221f041..b8ec6258eb0d5 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -131,6 +131,10 @@ function isLoginAttemptWithProviderType( ); } +function isSessionAuthenticated(sessionValue?: Readonly | null) { + return !!sessionValue?.username; +} + /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -558,7 +562,7 @@ export class Authenticator { return ownsSession ? { value: existingSessionValue, overwritten: false } : null; } - const isExistingSessionAuthenticated = !!existingSessionValue?.username; + const isExistingSessionAuthenticated = isSessionAuthenticated(existingSessionValue); const isNewSessionAuthenticated = !!authenticationResult.user; const providerHasChanged = !!existingSessionValue && !ownsSession; @@ -637,7 +641,7 @@ export class Authenticator { // 4. Request isn't attributed with HTTP Authorization header return ( canRedirectRequest(request) && - !sessionValue && + !isSessionAuthenticated(sessionValue) && this.options.config.authc.selector.enabled && HTTPAuthorizationHeader.parseFromRequest(request) == null ); @@ -688,14 +692,14 @@ export class Authenticator { return authenticationResult; } - const isSessionAuthenticated = !!sessionUpdateResult?.value?.username; + const isUpdatedSessionAuthenticated = isSessionAuthenticated(sessionUpdateResult?.value); let preAccessRedirectURL; - if (isSessionAuthenticated && sessionUpdateResult?.overwritten) { + if (isUpdatedSessionAuthenticated && sessionUpdateResult?.overwritten) { this.logger.debug('Redirecting user to the overwritten session UI.'); preAccessRedirectURL = `${this.options.basePath.serverBasePath}${OVERWRITTEN_SESSION_ROUTE}`; } else if ( - isSessionAuthenticated && + isUpdatedSessionAuthenticated && this.shouldRedirectToAccessAgreement(sessionUpdateResult?.value ?? null) ) { this.logger.debug('Redirecting user to the access agreement UI.'); diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index ec7a49da469fe..f0254616e6c9d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1215,6 +1215,7 @@ export class EndpointDocGenerator { install_version: '0.5.0', install_status: 'installed', install_started_at: '2020-06-24T14:41:23.098Z', + install_source: 'registry', }, references: [], updated_at: '2020-06-24T14:41:23.098Z', diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index b4e837c472915..87d83e5bb710e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -29,7 +29,7 @@ export const GetTrustedAppsRequestSchema = { export const PostTrustedAppCreateRequestSchema = { body: schema.object({ name: schema.string({ minLength: 1 }), - description: schema.maybe(schema.string({ minLength: 0, defaultValue: '' })), + description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), os: schema.oneOf([schema.literal('linux'), schema.literal('macos'), schema.literal('windows')]), entries: schema.arrayOf( schema.object({ diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index abb0ccee8d909..0054c1f1abdd5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -718,7 +718,10 @@ export type SafeEndpointEvent = Partial<{ forwarded_ip: ECSField; }>; dns: Partial<{ - question: Partial<{ name: ECSField }>; + question: Partial<{ + name: ECSField; + type: ECSField; + }>; }>; process: Partial<{ entity_id: ECSField; diff --git a/x-pack/plugins/security_solution/cypress/fixtures/overview.json b/x-pack/plugins/security_solution/cypress/fixtures/overview.json deleted file mode 100644 index c4aeda0c446e4..0000000000000 --- a/x-pack/plugins/security_solution/cypress/fixtures/overview.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": { - "source": { - "id": "default", - "status": { - "indicesExist": true, - "indexFields": [], - "__typename": "SourceStatus" - }, - "__typename": "Source" - } - } -} diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts index ca7832603f13d..13e5edd1cfe23 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { eqlRule, indexPatterns } from '../objects/rule'; +import { eqlRule, eqlSequenceRule, indexPatterns } from '../objects/rule'; import { ALERT_RULE_METHOD, @@ -85,8 +85,10 @@ const expectedMitre = eqlRule.mitre .join(''); const expectedNumberOfRules = 1; const expectedNumberOfAlerts = 7; +const expectedNumberOfSequenceAlerts = 1; -describe('Detection rules, EQL', () => { +// Failing: See https://github.com/elastic/kibana/issues/79522 +describe.skip('Detection rules, EQL', () => { before(() => { esArchiverLoad('timeline'); }); @@ -172,4 +174,43 @@ describe('Detection rules, EQL', () => { cy.get(ALERT_RULE_SEVERITY).first().should('have.text', eqlRule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', eqlRule.riskScore); }); + + it('Creates and activates a new EQL rule with a sequence', () => { + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + goToManageAlertsDetectionRules(); + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); + goToCreateNewRule(); + selectEqlRuleType(); + fillDefineEqlRuleAndContinue(eqlSequenceRule); + fillAboutRuleAndContinue(eqlSequenceRule); + fillScheduleRuleAndContinue(eqlSequenceRule); + createAndActivateRule(); + + cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); + + changeToThreeHundredRowsPerPage(); + waitForRulesToBeLoaded(); + + cy.get(RULES_TABLE).then(($table) => { + cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + }); + + filterByCustomRules(); + goToRuleDetails(); + refreshPage(); + waitForTheRuleToBeExecuted(); + + cy.get(NUMBER_OF_ALERTS) + .invoke('text') + .then((numberOfAlertsText) => { + cy.wrap(parseInt(numberOfAlertsText, 10)).should('eql', expectedNumberOfSequenceAlerts); + }); + cy.get(ALERT_RULE_NAME).first().should('have.text', eqlSequenceRule.name); + cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); + cy.get(ALERT_RULE_METHOD).first().should('have.text', 'eql'); + cy.get(ALERT_RULE_SEVERITY).first().should('have.text', eqlSequenceRule.severity.toLowerCase()); + cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', eqlSequenceRule.riskScore); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts index 14464333fcafe..e2f5ca9025bd9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -13,8 +13,8 @@ import { OVERVIEW_URL } from '../urls/navigation'; describe('Overview Page', () => { before(() => { - cy.stubSecurityApi('overview'); - cy.stubSearchStrategyApi('overview_search_strategy'); + cy.stubSearchStrategyApi('overviewHostQuery', 'overview_search_strategy'); + cy.stubSearchStrategyApi('overviewNetworkQuery', 'overview_search_strategy'); loginAndWaitForPage(OVERVIEW_URL); }); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index f375eccd902c4..0bb4c8e356091 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -230,6 +230,25 @@ export const eqlRule: CustomRule = { lookBack, }; +export const eqlSequenceRule: CustomRule = { + customQuery: + 'sequence with maxspan=30s\ + [any where process.name == "which"]\ + [any where process.name == "xargs"]', + name: 'New EQL Sequence Rule', + description: 'New EQL rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['https://www.google.com/', 'https://elastic.co/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [mitre1, mitre2], + note: '# test markdown', + timelineId: '0162c130-78be-11ea-9718-118a926974a4', + runsEvery, + lookBack, +}; + export const indexPatterns = [ 'apm-*-transaction*', 'auditbeat-*', diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index 0e3c9562aedf0..0dbcb1af4642f 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -39,13 +39,17 @@ Cypress.Commands.add('stubSecurityApi', function (dataFileName) { cy.route('POST', 'api/solutions/security/graphql', `@${dataFileName}JSON`); }); -Cypress.Commands.add('stubSearchStrategyApi', function (dataFileName) { +Cypress.Commands.add('stubSearchStrategyApi', function ( + queryId, + dataFileName, + searchStrategyName = 'securitySolutionSearchStrategy' +) { cy.on('window:before:load', (win) => { win.fetch = null; }); cy.server(); cy.fixture(dataFileName).as(`${dataFileName}JSON`); - cy.route('POST', 'internal/search/securitySolutionSearchStrategy', `@${dataFileName}JSON`); + cy.route('POST', `internal/search/${searchStrategyName}/${queryId}`, `@${dataFileName}JSON`); }); Cypress.Commands.add( diff --git a/x-pack/plugins/security_solution/cypress/support/index.d.ts b/x-pack/plugins/security_solution/cypress/support/index.d.ts index f0b0b8c92c616..59180507cbade 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -8,7 +8,11 @@ declare namespace Cypress { interface Chainable { promisify(): Promise; stubSecurityApi(dataFileName: string): Chainable; - stubSearchStrategyApi(dataFileName: string): Chainable; + stubSearchStrategyApi( + queryId: string, + dataFileName: string, + searchStrategyName?: string + ): Chainable; attachFile(fileName: string, fileType?: string): Chainable; } } diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 914566a13a9a9..079c18b6abe6e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -223,7 +223,6 @@ export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => { export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { cy.get(EQL_QUERY_INPUT).type(rule.customQuery); - cy.get(EQL_QUERY_INPUT).invoke('text').should('eq', rule.customQuery); cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); cy.get(EQL_QUERY_INPUT).should('not.exist'); diff --git a/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts b/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts index 87f8f46affb52..1029bd35f35f6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts +++ b/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts @@ -23,7 +23,6 @@ export const mockFormHook = { setFieldErrors: jest.fn(), getFields: jest.fn(), getFormData: jest.fn(), - getFieldDefaultValue: jest.fn(), /* Returns a list of all errors in the form */ getErrors: jest.fn(), reset: jest.fn(), @@ -34,6 +33,7 @@ export const mockFormHook = { __validateFields: jest.fn(), __updateFormDataAt: jest.fn(), __readFieldConfigFromSchema: jest.fn(), + __getFieldDefaultValue: jest.fn(), }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const getFormMock = (sampleData: any) => ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx index 95ef3353a025f..94d694a9e107a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx @@ -27,7 +27,7 @@ interface EditConnectorProps { connectors: Connector[]; disabled?: boolean; isLoading: boolean; - onSubmit: (a: string[], onSuccess: () => void, onError: () => void) => void; + onSubmit: (a: string, onSuccess: () => void, onError: () => void) => void; selectedConnector: string; } @@ -48,7 +48,13 @@ export const EditConnector = React.memo( onSubmit, selectedConnector, }: EditConnectorProps) => { - const initialState = { connectors }; + const initialState: { + connectors: Connector[]; + connector: string | undefined; + } = { + connectors, + connector: undefined, + }; const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx index d37de2cd3ec3d..c3592360672ad 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx @@ -53,7 +53,7 @@ export const getDefaultWhenTooltipIsUnspecified = ({ /** * Renders the content of the draggable, wrapped in a tooltip */ -const Content = React.memo<{ +export const Content = React.memo<{ children?: React.ReactNode; field: string; tooltipContent?: React.ReactNode; 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 7859f5584b0e5..6ed9765a911d0 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 @@ -312,6 +312,7 @@ const EventsViewerComponent: React.FC = ({ onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadPage} serverSideEventCount={totalCountMinusDeleted} + showMorePagesIndicator={pageInfo.showMorePagesIndicator} totalCount={pageInfo.fakeTotalCount} /> ) diff --git a/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap index 4bd2cd05d49d0..61e9dd04d910d 100644 --- a/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap @@ -200,7 +200,11 @@ exports[`item_details_card ItemDetailsPropertySummary should render correctly 1` name 1 - value 1 + + value 1 + `; diff --git a/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx b/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx index ee1c3e1bead1a..9105514b75807 100644 --- a/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx @@ -52,23 +52,30 @@ const DetailsSection = styled(EuiFlexItem)` `; const DescriptionListTitle = styled(EuiDescriptionListTitle)` - width: 40%; + &&& { + width: 40%; + } `; const DescriptionListDescription = styled(EuiDescriptionListDescription)` - width: 60%; + &&& { + width: 60%; + } `; interface ItemDetailsPropertySummaryProps { name: ReactNode | ReactNode[]; value: ReactNode | ReactNode[]; + title?: string; } export const ItemDetailsPropertySummary: FC = memo( - ({ name, value }) => ( + ({ name, value, title = '' }) => ( <> {name} - {value} + + {value} + ) ); diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index af022fc3d525d..3df8663324fdd 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -66,7 +66,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiButtonHeight": "40px", "euiButtonHeightSmall": "32px", "euiButtonIconTypes": Object { - "danger": "#ff6666", + "accent": "#f990c0", + "danger": "#ff7575", "disabled": "#4c4e51", "ghost": "#ffffff", "primary": "#1ba9f5", @@ -217,7 +218,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiDataGridColumnResizerWidth": "3px", "euiDataGridPopoverMaxHeight": "400px", "euiDataGridPrefix": ".euiDataGrid--", - "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'fontSizeSmall', 'fontSizeLarge', 'noControls'", + "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'footerShade', 'footerOverline', 'fontSizeSmall', 'fontSizeLarge', 'noControls', 'stickyFooter'", "euiDataGridVerticalBorder": "solid 1px #24272e", "euiDatePickerCalendarWidth": "284px", "euiDragAndDropSpacing": Object { @@ -292,9 +293,15 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiHeaderChildSize": "48px", "euiHeaderHeight": "48px", "euiHeaderHeightCompensation": "49px", + "euiHeaderLinksGutterSizes": Object { + "gutterL": "24px", + "gutterM": "12px", + "gutterS": "8px", + "gutterXS": "4px", + }, "euiIconColors": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "ghost": "#ffffff", "primary": "#1ba9f5", "secondary": "#7de2d1", diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index f7b69c4fc8ed3..c0d540d01ee97 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -325,7 +325,7 @@ const FooterAction = styled(EuiFlexGroup).attrs(() => ({ FooterAction.displayName = 'FooterAction'; -const PaginationEuiFlexItem = styled(EuiFlexItem)` +export const PaginationEuiFlexItem = styled(EuiFlexItem)` @media only screen and (min-width: ${({ theme }) => theme.eui.euiBreakpoints.m}) { .euiButtonIcon:last-child { margin-left: 28px; diff --git a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts index dc2d6605bc292..ab44cbd65516e 100644 --- a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts @@ -61,7 +61,7 @@ export const useTimelineLastEventTime = ({ details, }); - const [TimelineLastEventTimeResponse, setTimelineLastEventTimeResponse] = useState< + const [timelineLastEventTimeResponse, setTimelineLastEventTimeResponse] = useState< UseTimelineLastEventTimeArgs >({ lastSeen: null, @@ -151,5 +151,5 @@ export const useTimelineLastEventTime = ({ timelineLastEventTimeSearch(TimelineLastEventTimeRequest); }, [TimelineLastEventTimeRequest, timelineLastEventTimeSearch]); - return [loading, TimelineLastEventTimeResponse]; + return [loading, timelineLastEventTimeResponse]; }; diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts index 33cc86f5e9798..54e349fe3e926 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts @@ -34,6 +34,8 @@ export interface UseMatrixHistogramArgs { totalCount: number; } +const ID = 'matrixHistogramQuery'; + export const useMatrixHistogram = ({ endDate, errorMessage, @@ -54,6 +56,7 @@ export const useMatrixHistogram = ({ factoryQueryType: MatrixHistogramQuery, filterQuery: createFilter(filterQuery), histogramType, + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts index c4a9540f62914..6fed7db62aa3c 100644 --- a/x-pack/plugins/security_solution/public/common/translations.ts +++ b/x-pack/plugins/security_solution/public/common/translations.ts @@ -50,7 +50,7 @@ export const EMPTY_ACTION_SECONDARY = i18n.translate( export const EMPTY_ACTION_ENDPOINT = i18n.translate( 'xpack.securitySolution.pages.common.emptyActionEndpoint', { - defaultMessage: 'Add Elastic Endpoint Security', + defaultMessage: 'Add Endpoint Security', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index 83d087e60bc7d..7d509270fff95 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -125,9 +125,7 @@ export const StepRuleDescriptionComponent = ({ ); }; -export const StepRuleDescription = memo( - StepRuleDescriptionComponent -) as typeof StepRuleDescriptionComponent; +export const StepRuleDescription = memo(StepRuleDescriptionComponent); export const buildListItems = ( data: unknown, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts index 9b0cec99b1b38..66fe138c30ad5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts @@ -17,7 +17,7 @@ export const PRE_BUILT_MSG = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.prePackagedRules.emptyPromptMessage', { defaultMessage: - 'Elastic Security comes with prebuilt detection rules that run in the background and create alerts when their conditions are met. By default, all prebuilt rules except the Elastic Endpoint Security rule are disabled. You can select additional rules you want to activate.', + 'Elastic Security comes with prebuilt detection rules that run in the background and create alerts when their conditions are met. By default, all prebuilt rules except the Endpoint Security rule are disabled. You can select additional rules you want to activate.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index d13635bfd1b50..07a67ae8705c3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -19,10 +19,7 @@ import { hasMlAdminPermissions } from '../../../../../common/machine_learning/ha import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../common/lib/kibana'; -import { - filterRuleFieldsForType, - RuleFields, -} from '../../../pages/detection_engine/rules/create/helpers'; +import { filterRuleFieldsForType } from '../../../pages/detection_engine/rules/create/helpers'; import { DefineStepRule, RuleStep, @@ -223,7 +220,7 @@ const StepDefineRuleComponent: FC = ({ diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx index 2a4609a2f5e9e..9a3dcc7a4d713 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx @@ -17,6 +17,7 @@ import { UseField, getFieldValidityAndErrorMessage, } from '../../../../shared_imports'; +import { DefineStepRule } from '../../../pages/detection_engine/rules/types'; import { schema } from '../step_define_rule/schema'; import { QueryBarDefineRule } from '../query_bar'; import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; @@ -51,7 +52,7 @@ const ThreatMatchInputComponent: React.FC = ({ - path="threatIndex" config={{ ...schema.threatIndex, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 160809a2ba3cd..540fdc6bc75f5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -110,7 +110,7 @@ const isThreatMatchFields = ( fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields | ThreatMatchRuleFields ): fields is ThreatMatchRuleFields => has('threatIndex', fields); -export const filterRuleFieldsForType = ( +export const filterRuleFieldsForType = >( fields: T, type: Type ): QueryRuleFields | MlRuleFields | ThresholdRuleFields | ThreatMatchRuleFields => { diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx index bc7137097c646..6418ea83d97f9 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx @@ -37,7 +37,7 @@ import { hostsModel, hostsSelectors } from '../../store'; import * as i18n from './translations'; -const ID = 'authenticationQuery'; +const ID = 'hostsAuthenticationsQuery'; export interface AuthenticationArgs { authentications: AuthenticationsEdges[]; @@ -78,25 +78,35 @@ export const useAuthentications = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [authenticationsRequest, setAuthenticationsRequest] = useState< - HostAuthenticationsRequestOptions - >({ - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.authentications, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - sort: {} as SortField, - }); + const [ + authenticationsRequest, + setAuthenticationsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.authentications, + filterQuery: createFilter(filterQuery), + id: ID, + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + sort: {} as SortField, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { setAuthenticationsRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + return { ...prevRequest, pagination: generateTablePaginationOptions(newActivePage, limit), @@ -126,7 +136,11 @@ export const useAuthentications = ({ }); const authenticationsSearch = useCallback( - (request: HostAuthenticationsRequestOptions) => { + (request: HostAuthenticationsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -184,16 +198,19 @@ export const useAuthentications = ({ useEffect(() => { setAuthenticationsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.authentications, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', from: startDate, to: endDate, }, + sort: {} as SortField, }; if (!skip && !deepEqual(prevRequest, myRequest)) { return myRequest; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx index 5b69e20398a35..2dec01dc4d9e3 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx @@ -28,7 +28,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostDetailsQuery'; +const ID = 'hostsDetailsQuery'; export interface HostDetailsArgs { id: string; @@ -60,16 +60,21 @@ export const useHostDetails = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostDetailsRequest, setHostDetailsRequest] = useState({ - defaultIndex: indexNames, - hostName, - factoryQueryType: HostsQueries.details, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [hostDetailsRequest, setHostDetailsRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + hostName, + id, + factoryQueryType: HostsQueries.details, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [hostDetailsResponse, setHostDetailsResponse] = useState({ endDate, @@ -84,7 +89,11 @@ export const useHostDetails = ({ }); const hostDetailsSearch = useCallback( - (request: HostDetailsRequestOptions) => { + (request: HostDetailsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -141,9 +150,11 @@ export const useHostDetails = ({ useEffect(() => { setHostDetailsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsQueries.details, hostName, + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx index 77f4567fc6a5f..9dd4881b3c9ff 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx @@ -33,7 +33,7 @@ import { import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; -const ID = 'hostsQuery'; +const ID = 'hostsAllQuery'; type LoadPage = (newActivePage: number) => void; export interface HostsArgs { @@ -76,29 +76,40 @@ export const useAllHost = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.hosts, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - sort: { - direction, - field: sortField, - }, - }); + const [hostsRequest, setHostRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.hosts, + filterQuery: createFilter(filterQuery), + id: ID, + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + sort: { + direction, + field: sortField, + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setHostRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setHostRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -124,7 +135,11 @@ export const useAllHost = ({ }); const hostsSearch = useCallback( - (request: HostsRequestOptions) => { + (request: HostsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -180,10 +195,12 @@ export const useAllHost = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.hosts, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx index 404231be1e6cd..90be23b48786c 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx @@ -52,19 +52,24 @@ export const useHostsKpiAuthentications = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsKpiAuthenticationsRequest, setHostsKpiAuthenticationsRequest] = useState< - HostsKpiAuthenticationsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiAuthentications, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + hostsKpiAuthenticationsRequest, + setHostsKpiAuthenticationsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiAuthentications, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [hostsKpiAuthenticationsResponse, setHostsKpiAuthenticationsResponse] = useState< HostsKpiAuthenticationsArgs @@ -83,7 +88,11 @@ export const useHostsKpiAuthentications = ({ }); const hostsKpiAuthenticationsSearch = useCallback( - (request: HostsKpiAuthenticationsRequestOptions) => { + (request: HostsKpiAuthenticationsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -146,9 +155,11 @@ export const useHostsKpiAuthentications = ({ useEffect(() => { setHostsKpiAuthenticationsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiAuthentications, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx index bb918a9214f40..2bb08dec78e8f 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx @@ -51,17 +51,24 @@ export const useHostsKpiHosts = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsKpiHostsRequest, setHostsKpiHostsRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiHosts, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + hostsKpiHostsRequest, + setHostsKpiHostsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiHosts, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [hostsKpiHostsResponse, setHostsKpiHostsResponse] = useState({ hosts: 0, @@ -76,7 +83,11 @@ export const useHostsKpiHosts = ({ }); const hostsKpiHostsSearch = useCallback( - (request: HostsKpiHostsRequestOptions) => { + (request: HostsKpiHostsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -134,9 +145,11 @@ export const useHostsKpiHosts = ({ useEffect(() => { setHostsKpiHostsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiHosts, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx index b8e93eef8dc91..e5ef53643ff53 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx @@ -52,19 +52,24 @@ export const useHostsKpiUniqueIps = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsKpiUniqueIpsRequest, setHostsKpiUniqueIpsRequest] = useState< - HostsKpiUniqueIpsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiUniqueIps, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + hostsKpiUniqueIpsRequest, + setHostsKpiUniqueIpsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiUniqueIps, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [hostsKpiUniqueIpsResponse, setHostsKpiUniqueIpsResponse] = useState( { @@ -83,7 +88,11 @@ export const useHostsKpiUniqueIps = ({ ); const hostsKpiUniqueIpsSearch = useCallback( - (request: HostsKpiUniqueIpsRequestOptions) => { + (request: HostsKpiUniqueIpsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -143,9 +152,11 @@ export const useHostsKpiUniqueIps = ({ useEffect(() => { setHostsKpiUniqueIpsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiUniqueIps, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx index 4036837024025..2bf97c896f5e5 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx @@ -35,7 +35,7 @@ import { ESTermQuery } from '../../../../common/typed_json'; import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; -const ID = 'uncommonProcessesQuery'; +const ID = 'hostsUncommonProcessesQuery'; export interface UncommonProcessesArgs { id: string; @@ -75,25 +75,35 @@ export const useUncommonProcesses = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [uncommonProcessesRequest, setUncommonProcessesRequest] = useState< - HostsUncommonProcessesRequestOptions - >({ - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.uncommonProcesses, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - sort: {} as SortField, - }); + const [ + uncommonProcessesRequest, + setUncommonProcessesRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.uncommonProcesses, + filterQuery: createFilter(filterQuery), + id: ID, + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate!, + to: endDate!, + }, + sort: {} as SortField, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { setUncommonProcessesRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + return { ...prevRequest, pagination: generateTablePaginationOptions(newActivePage, limit), @@ -124,7 +134,11 @@ export const useUncommonProcesses = ({ ); const uncommonProcessesSearch = useCallback( - (request: HostsUncommonProcessesRequestOptions) => { + (request: HostsUncommonProcessesRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -185,10 +199,12 @@ export const useUncommonProcesses = ({ useEffect(() => { setUncommonProcessesRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.uncommonProcesses, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index 4617865d6aa6d..d51a23639f5cb 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -58,7 +58,7 @@ const PolicyEmptyState = React.memo<{

@@ -66,21 +66,21 @@ const PolicyEmptyState = React.memo<{ } bodyComponent={ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 4bb9335496ef4..7639b878b9c5c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -570,7 +570,7 @@ export const EndpointList = () => { subtitle={ } > diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap index d33c74a021f86..bf5f5149b2ef2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap @@ -494,11 +494,11 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = ` padding: 16px; } -.c1 { +.c1.c1.c1 { width: 40%; } -.c2 { +.c2.c2.c2 { width: 60%; } @@ -791,7 +791,11 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = `
- trusted app 0 + + trusted app 0 +
- Windows + + Windows +
- 1 minute ago + + 1 minute ago +
- someone + + someone + + +
+ Description +
+
+ + Trusted App 0 +
diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 08cb1835c5363..198548b642dd2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -410,6 +410,7 @@ export const CreateTrustedAppForm = memo( value={formValues.description} onChange={handleDomChangeEvents} fullWidth + maxLength={256} data-test-subj={getTestId('descriptionField')} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap index 3928f4ddec837..1d33a06c507a3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap @@ -24,6 +24,81 @@ exports[`trusted_app_card TrustedAppCard should render correctly 1`] = ` name="Created By" value="someone" /> + + + + Remove + + +`; + +exports[`trusted_app_card TrustedAppCard should trim long descriptions 1`] = ` + + + + + } + /> + + ( )); +const PATH_CONDITION: WindowsConditionEntry = { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: '/some/path/on/file/system', +}; + +const SIGNER_CONDITION: WindowsConditionEntry = { + field: 'process.code_signature', + operator: 'included', + type: 'match', + value: 'Elastic', +}; + storiesOf('TrustedApps|TrustedAppCard', module) .add('default', () => { const trustedApp: TrustedApp = createSampleTrustedApp(5); trustedApp.created_at = '2020-09-17T14:52:33.899Z'; - trustedApp.entries = [ - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: '/some/path/on/file/system', - }, - ]; + trustedApp.entries = [PATH_CONDITION]; return ; }) .add('multiple entries', () => { const trustedApp: TrustedApp = createSampleTrustedApp(5); trustedApp.created_at = '2020-09-17T14:52:33.899Z'; - trustedApp.entries = [ - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: '/some/path/on/file/system', - }, - { - field: 'process.code_signature', - operator: 'included', - type: 'match', - value: 'Elastic', - }, - ]; + trustedApp.entries = [PATH_CONDITION, SIGNER_CONDITION]; + + return ; + }) + .add('trim description', () => { + const trustedApp: TrustedApp = createSampleTrustedApp(5); + trustedApp.created_at = '2020-09-17T14:52:33.899Z'; + trustedApp.entries = [PATH_CONDITION, SIGNER_CONDITION]; + trustedApp.description = [...new Array(40).keys()].map((index) => `item${index}`).join(' '); return ; }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx index 163883b3dc3b8..1e2d18aea20fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx @@ -18,5 +18,15 @@ describe('trusted_app_card', () => { expect(element).toMatchSnapshot(); }); + + it('should trim long descriptions', () => { + const trustedApp = { + ...createSampleTrustedApp(4), + description: [...new Array(40).keys()].map((index) => `item${index}`).join(' '), + }; + const element = shallow( {}} />); + + expect(element).toMatchSnapshot(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx index 73dbe5482573a..95a9fd8a6b84d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx @@ -27,6 +27,14 @@ import { OS_TITLES, PROPERTY_TITLES, ENTRY_PROPERTY_TITLES } from '../../transla type Entry = MacosLinuxConditionEntry | WindowsConditionEntry; +const trimTextOverflow = (text: string, maxSize: number) => { + if (text.length > maxSize) { + return `${text.substr(0, maxSize)}...`; + } else { + return text; + } +}; + const getEntriesColumnDefinitions = (): Array> => [ { field: 'field', @@ -75,6 +83,13 @@ export const TrustedAppCard = memo(({ trustedApp, onDelete }: TrustedAppCardProp } /> + trimTextOverflow(trustedApp.description || '', 100), [ + trustedApp.description, + ])} + title={trustedApp.description} + /> getEntriesColumnDefinitions(), [])} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index e16155df6d2db..b05db549b6c60 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -24,7 +24,7 @@ export const OS_TITLES: Readonly<{ [K in TrustedApp['os']]: string }> = { }; export const PROPERTY_TITLES: Readonly< - { [K in keyof Omit]: string } + { [K in keyof Omit]: string } > = { name: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.name', { defaultMessage: 'Name', @@ -38,6 +38,9 @@ export const PROPERTY_TITLES: Readonly< created_by: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.createdBy', { defaultMessage: 'Created By', }), + description: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.description', { + defaultMessage: 'Description', + }), }; export const ENTRY_PROPERTY_TITLES: Readonly< diff --git a/x-pack/plugins/security_solution/public/network/components/ip/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/ip/__snapshots__/index.test.tsx.snap index a2c71b914b989..2e1465da73a4c 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/ip/__snapshots__/index.test.tsx.snap @@ -7,6 +7,7 @@ exports[`Port renders correctly against snapshot 1`] = ` eventId="abcd" fieldName="destination.ip" fieldType="ip" + truncate={true} value="10.1.2.3" /> `; diff --git a/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx index 39ecb27606181..8afc22d799a7d 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx @@ -43,7 +43,7 @@ describe('Port', () => { ); expect( - wrapper.find('[data-test-subj="draggable-content-destination.ip"]').find('a').first().props() + wrapper.find('[data-test-subj="draggable-truncatable-content"]').find('a').first().props() .href ).toEqual('/ip/10.1.2.3/source'); }); diff --git a/x-pack/plugins/security_solution/public/network/components/ip/index.tsx b/x-pack/plugins/security_solution/public/network/components/ip/index.tsx index 21e2dd3ebc04d..701094cee88a2 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip/index.tsx @@ -30,6 +30,7 @@ export const Ip = React.memo<{ fieldName={fieldName} fieldType={IP_FIELD_TYPE} value={value} + truncate /> )); diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx index 847b6a3ced554..890add4222503 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx @@ -772,7 +772,7 @@ describe('SourceDestinationIp', () => { ); - expect(wrapper.find('[data-test-subj="draggable-content-source.ip"]').first().text()).toEqual( + expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').first().text()).toEqual( '192.168.1.2' ); }); @@ -823,7 +823,7 @@ describe('SourceDestinationIp', () => { ); - expect(wrapper.find('[data-test-subj="draggable-content-source.ip"]').first().text()).toEqual( + expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').first().text()).toEqual( '192.168.1.2' ); }); @@ -874,9 +874,9 @@ describe('SourceDestinationIp', () => { ); - expect( - wrapper.find('[data-test-subj="draggable-content-destination.ip"]').first().text() - ).toEqual('10.1.2.3'); + expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').first().text()).toEqual( + '10.1.2.3' + ); }); test('it renders the expected destination IP when type is `destination`, but the length of the destinationIp and destinationPort port arrays is different', () => { @@ -925,9 +925,9 @@ describe('SourceDestinationIp', () => { ); - expect( - wrapper.find('[data-test-subj="draggable-content-destination.ip"]').first().text() - ).toEqual('10.1.2.3'); + expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').first().text()).toEqual( + '10.1.2.3' + ); }); test('it renders the expected source port when type is `source`, and both sourceIp and sourcePort are populated', () => { diff --git a/x-pack/plugins/security_solution/public/network/containers/details/index.tsx b/x-pack/plugins/security_solution/public/network/containers/details/index.tsx index 217241bdadcbb..238270107b071 100644 --- a/x-pack/plugins/security_solution/public/network/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/details/index.tsx @@ -59,13 +59,21 @@ export const useNetworkDetails = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkDetailsRequest, setNetworkDetailsRequest] = useState({ - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: NetworkQueries.details, - filterQuery: createFilter(filterQuery), - ip, - }); + const [ + networkDetailsRequest, + setNetworkDetailsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: NetworkQueries.details, + filterQuery: createFilter(filterQuery), + id, + ip, + } + : null + ); const [networkDetailsResponse, setNetworkDetailsResponse] = useState({ networkDetails: {}, @@ -79,7 +87,11 @@ export const useNetworkDetails = ({ }); const networkDetailsSearch = useCallback( - (request: NetworkDetailsRequestOptions) => { + (request: NetworkDetailsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -136,18 +148,20 @@ export const useNetworkDetails = ({ useEffect(() => { setNetworkDetailsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, - ip, docValueFields: docValueFields ?? [], + factoryQueryType: NetworkQueries.details, filterQuery: createFilter(filterQuery), + id, + ip, }; if (!skip && !deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, filterQuery, skip, ip, docValueFields]); + }, [indexNames, filterQuery, skip, ip, docValueFields, id]); useEffect(() => { networkDetailsSearch(networkDetailsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx index dc60bb0a82ba8..aa0e607fc3c05 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx @@ -56,17 +56,24 @@ export const useNetworkKpiDns = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiDnsRequest, setNetworkKpiDnsRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.dns, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiDnsRequest, + setNetworkKpiDnsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.dns, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiDnsResponse, setNetworkKpiDnsResponse] = useState({ dnsQueries: 0, @@ -80,7 +87,11 @@ export const useNetworkKpiDns = ({ }); const networkKpiDnsSearch = useCallback( - (request: NetworkKpiDnsRequestOptions) => { + (request: NetworkKpiDnsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -137,9 +148,11 @@ export const useNetworkKpiDns = ({ useEffect(() => { setNetworkKpiDnsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.dns, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx index a1727d5bb4331..9ab14602140f7 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx @@ -56,19 +56,24 @@ export const useNetworkKpiNetworkEvents = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiNetworkEventsRequest, setNetworkKpiNetworkEventsRequest] = useState< - NetworkKpiNetworkEventsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.networkEvents, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiNetworkEventsRequest, + setNetworkKpiNetworkEventsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.networkEvents, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiNetworkEventsResponse, setNetworkKpiNetworkEventsResponse] = useState< NetworkKpiNetworkEventsArgs @@ -84,7 +89,11 @@ export const useNetworkKpiNetworkEvents = ({ }); const networkKpiNetworkEventsSearch = useCallback( - (request: NetworkKpiNetworkEventsRequestOptions) => { + (request: NetworkKpiNetworkEventsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -144,9 +153,11 @@ export const useNetworkKpiNetworkEvents = ({ useEffect(() => { setNetworkKpiNetworkEventsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.networkEvents, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx index bcbe485e82163..bc32395c100f2 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx @@ -56,19 +56,24 @@ export const useNetworkKpiTlsHandshakes = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiTlsHandshakesRequest, setNetworkKpiTlsHandshakesRequest] = useState< - NetworkKpiTlsHandshakesRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.tlsHandshakes, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiTlsHandshakesRequest, + setNetworkKpiTlsHandshakesRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.tlsHandshakes, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiTlsHandshakesResponse, setNetworkKpiTlsHandshakesResponse] = useState< NetworkKpiTlsHandshakesArgs @@ -84,7 +89,10 @@ export const useNetworkKpiTlsHandshakes = ({ }); const networkKpiTlsHandshakesSearch = useCallback( - (request: NetworkKpiTlsHandshakesRequestOptions) => { + (request: NetworkKpiTlsHandshakesRequestOptions | null) => { + if (request == null) { + return; + } let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -144,9 +152,11 @@ export const useNetworkKpiTlsHandshakes = ({ useEffect(() => { setNetworkKpiTlsHandshakesRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.tlsHandshakes, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx index a4fdefc93fe75..256953efac146 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx @@ -56,19 +56,24 @@ export const useNetworkKpiUniqueFlows = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiUniqueFlowsRequest, setNetworkKpiUniqueFlowsRequest] = useState< - NetworkKpiUniqueFlowsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.uniqueFlows, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiUniqueFlowsRequest, + setNetworkKpiUniqueFlowsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.uniqueFlows, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiUniqueFlowsResponse, setNetworkKpiUniqueFlowsResponse] = useState< NetworkKpiUniqueFlowsArgs @@ -84,7 +89,11 @@ export const useNetworkKpiUniqueFlows = ({ }); const networkKpiUniqueFlowsSearch = useCallback( - (request: NetworkKpiUniqueFlowsRequestOptions) => { + (request: NetworkKpiUniqueFlowsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -144,9 +153,11 @@ export const useNetworkKpiUniqueFlows = ({ useEffect(() => { setNetworkKpiUniqueFlowsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.uniqueFlows, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx index 5e9d829077f23..54307eb7c4c1d 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx @@ -60,19 +60,24 @@ export const useNetworkKpiUniquePrivateIps = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiUniquePrivateIpsRequest, setNetworkKpiUniquePrivateIpsRequest] = useState< - NetworkKpiUniquePrivateIpsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.uniquePrivateIps, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiUniquePrivateIpsRequest, + setNetworkKpiUniquePrivateIpsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.uniquePrivateIps, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiUniquePrivateIpsResponse, setNetworkKpiUniquePrivateIpsResponse] = useState< NetworkKpiUniquePrivateIpsArgs @@ -91,7 +96,11 @@ export const useNetworkKpiUniquePrivateIps = ({ }); const networkKpiUniquePrivateIpsSearch = useCallback( - (request: NetworkKpiUniquePrivateIpsRequestOptions) => { + (request: NetworkKpiUniquePrivateIpsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -155,9 +164,11 @@ export const useNetworkKpiUniquePrivateIps = ({ useEffect(() => { setNetworkKpiUniquePrivateIpsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.uniquePrivateIps, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx index c49aa6a415904..576fc810e9c5f 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx @@ -61,7 +61,6 @@ interface UseNetworkDns { export const useNetworkDns = ({ endDate, filterQuery, - id = ID, indexNames, skip, startDate, @@ -74,26 +73,37 @@ export const useNetworkDns = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkDnsRequest, setNetworkDnsRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.dns, - filterQuery: createFilter(filterQuery), - isPtrIncluded, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [networkDnsRequest, setNetworkDnsRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.dns, + filterQuery: createFilter(filterQuery), + id: ID, + isPtrIncluded, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setNetworkDnsRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setNetworkDnsRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -118,7 +128,11 @@ export const useNetworkDns = ({ }); const networkDnsSearch = useCallback( - (request: NetworkDnsRequestOptions) => { + (request: NetworkDnsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -178,10 +192,12 @@ export const useNetworkDns = ({ useEffect(() => { setNetworkDnsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, isPtrIncluded, + factoryQueryType: NetworkQueries.dns, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx index ec4ac39599351..12c3cc481cfc1 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx @@ -76,23 +76,32 @@ export const useNetworkHttp = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkHttpRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.http, - filterQuery: createFilter(filterQuery), - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort: sort as SortField, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [networkHttpRequest, setHostRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.http, + filterQuery: createFilter(filterQuery), + id: ID, + ip, + pagination: generateTablePaginationOptions(activePage, limit), + sort: sort as SortField, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { setHostRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + return { ...prevRequest, pagination: generateTablePaginationOptions(newActivePage, limit), @@ -121,7 +130,11 @@ export const useNetworkHttp = ({ }); const networkHttpSearch = useCallback( - (request: NetworkHttpRequestOptions) => { + (request: NetworkHttpRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -180,9 +193,11 @@ export const useNetworkHttp = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.http, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), sort: sort as SortField, timerange: { diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index 2d75de138a88c..0b864d66842d1 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -5,7 +5,7 @@ */ import { noop } from 'lodash/fp'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { ESTermQuery } from '../../../../common/typed_json'; @@ -73,27 +73,42 @@ export const useNetworkTopCountries = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); + const queryId = useMemo(() => `${ID}-${flowTarget}`, [flowTarget]); - const [networkTopCountriesRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.topCountries, - filterQuery: createFilter(filterQuery), - flowTarget, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [ + networkTopCountriesRequest, + setHostRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.topCountries, + filterQuery: createFilter(filterQuery), + flowTarget, + id: queryId, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setHostRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setHostRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -102,7 +117,7 @@ export const useNetworkTopCountries = ({ NetworkTopCountriesArgs >({ networkTopCountries: [], - id: `${ID}-${flowTarget}`, + id: queryId, inspect: { dsl: [], response: [], @@ -119,7 +134,11 @@ export const useNetworkTopCountries = ({ }); const networkTopCountriesSearch = useCallback( - (request: NetworkTopCountriesRequestOptions) => { + (request: NetworkTopCountriesRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -178,9 +197,12 @@ export const useNetworkTopCountries = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.topCountries, filterQuery: createFilter(filterQuery), + flowTarget, + id: queryId, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { @@ -194,7 +216,18 @@ export const useNetworkTopCountries = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip]); + }, [ + activePage, + indexNames, + endDate, + filterQuery, + limit, + startDate, + sort, + skip, + flowTarget, + queryId, + ]); useEffect(() => { networkTopCountriesSearch(networkTopCountriesRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index 328bb5aabcbb8..c68ad2422c514 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -74,26 +74,40 @@ export const useNetworkTopNFlow = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkTopNFlowRequest, setTopNFlowRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.topNFlow, - filterQuery: createFilter(filterQuery), - flowTarget, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [ + networkTopNFlowRequest, + setTopNFlowRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.topNFlow, + filterQuery: createFilter(filterQuery), + flowTarget, + id: ID, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setTopNFlowRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setTopNFlowRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -117,7 +131,11 @@ export const useNetworkTopNFlow = ({ }); const networkTopNFlowSearch = useCallback( - (request: NetworkTopNFlowRequestOptions) => { + (request: NetworkTopNFlowRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -176,9 +194,12 @@ export const useNetworkTopNFlow = ({ useEffect(() => { setTopNFlowRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.topNFlow, filterQuery: createFilter(filterQuery), + flowTarget, + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', @@ -192,7 +213,7 @@ export const useNetworkTopNFlow = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip]); + }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip, flowTarget]); useEffect(() => { networkTopNFlowSearch(networkTopNFlowRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx index ddea2914a1bbb..09ade9c1bd885 100644 --- a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx @@ -75,28 +75,38 @@ export const useNetworkTls = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkTlsRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.tls, - filterQuery: createFilter(filterQuery), - flowTarget, - id, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [networkTlsRequest, setHostRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.tls, + filterQuery: createFilter(filterQuery), + flowTarget, + id, + ip, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setHostRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setHostRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -120,7 +130,11 @@ export const useNetworkTls = ({ }); const networkTlsSearch = useCallback( - (request: NetworkTlsRequestOptions) => { + (request: NetworkTlsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -176,9 +190,13 @@ export const useNetworkTls = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.tls, filterQuery: createFilter(filterQuery), + flowTarget, + id, + ip, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', @@ -192,7 +210,19 @@ export const useNetworkTls = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip]); + }, [ + activePage, + indexNames, + endDate, + filterQuery, + limit, + startDate, + sort, + skip, + flowTarget, + ip, + id, + ]); useEffect(() => { networkTlsSearch(networkTlsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx index 5bca8d773c2f6..2e83c9866c59a 100644 --- a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx @@ -73,27 +73,38 @@ export const useNetworkUsers = ({ const defaultIndex = uiSettings.get(DEFAULT_INDEX_KEY); const [loading, setLoading] = useState(false); - const [networkUsersRequest, setNetworkUsersRequest] = useState({ - defaultIndex, - factoryQueryType: NetworkQueries.users, - filterQuery: createFilter(filterQuery), - flowTarget, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [networkUsersRequest, setNetworkUsersRequest] = useState( + !skip + ? { + defaultIndex, + factoryQueryType: NetworkQueries.users, + filterQuery: createFilter(filterQuery), + flowTarget, + id, + ip, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setNetworkUsersRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setNetworkUsersRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -117,7 +128,11 @@ export const useNetworkUsers = ({ }); const networkUsersSearch = useCallback( - (request: NetworkUsersRequestOptions) => { + (request: NetworkUsersRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -176,9 +191,13 @@ export const useNetworkUsers = ({ useEffect(() => { setNetworkUsersRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), + id, + ip, defaultIndex, + factoryQueryType: NetworkQueries.users, filterQuery: createFilter(filterQuery), + flowTarget, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { @@ -192,7 +211,19 @@ export const useNetworkUsers = ({ } return prevRequest; }); - }, [activePage, defaultIndex, endDate, filterQuery, limit, startDate, sort, skip]); + }, [ + activePage, + defaultIndex, + endDate, + filterQuery, + limit, + startDate, + sort, + skip, + ip, + flowTarget, + id, + ]); useEffect(() => { networkUsersSearch(networkUsersRequest); diff --git a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx index 8b73253157edc..98c70908ca5d7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx @@ -33,7 +33,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => } @@ -49,7 +49,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index 2112350278e8e..2d4b8538a5d53 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -53,7 +53,7 @@ describe('OverviewEmpty', () => { description: 'Protect your hosts with threat prevention, detection, and deep security data visibility.', fill: false, - label: 'Add Elastic Endpoint Security', + label: 'Add Endpoint Security', onClick: undefined, url: '/app/home#/tutorial_directory/security', }, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 1d2c6889213f1..9b07d6a53537d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -63,7 +63,7 @@ const OverviewEmptyComponent: React.FC = () => { <> {i18nCommon.EMPTY_ACTION_SECONDARY} @@ -80,7 +80,7 @@ const OverviewEmptyComponent: React.FC = () => { <> {i18nCommon.EMPTY_ACTION_SECONDARY} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/__snapshots__/index.test.tsx.snap index 23732e88ba1f9..2e70d1a7f8a5b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/__snapshots__/index.test.tsx.snap @@ -254,7 +254,7 @@ exports[`Overview Host Stat Data rendering it renders the default OverviewHostSt > diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/index.tsx index ef595476d8a94..d2865c4385152 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/index.tsx @@ -209,7 +209,7 @@ const hostStatGroups: StatGroup[] = [ name: ( ), statIds: [ diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx index 946cd33088a45..e53915bc05fdf 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx @@ -55,16 +55,21 @@ export const useHostOverview = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [overviewHostRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: HostsQueries.overview, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [overviewHostRequest, setHostRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: HostsQueries.overview, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [overviewHostResponse, setHostOverviewResponse] = useState({ overviewHost: {}, @@ -78,7 +83,11 @@ export const useHostOverview = ({ }); const overviewHostSearch = useCallback( - (request: HostOverviewRequestOptions) => { + (request: HostOverviewRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -135,9 +144,11 @@ export const useHostOverview = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsQueries.overview, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx index 588fb1f08ef6f..96711917ca393 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx @@ -55,16 +55,24 @@ export const useNetworkOverview = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [overviewNetworkRequest, setNetworkRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.overview, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + overviewNetworkRequest, + setNetworkRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.overview, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [overviewNetworkResponse, setNetworkOverviewResponse] = useState({ overviewNetwork: {}, @@ -78,7 +86,11 @@ export const useNetworkOverview = ({ }); const overviewNetworkSearch = useCallback( - (request: NetworkOverviewRequestOptions) => { + (request: NetworkOverviewRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -135,9 +147,11 @@ export const useNetworkOverview = ({ useEffect(() => { setNetworkRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.overview, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx index 62e41d967cb9a..b6829bcce28fa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx @@ -21,6 +21,7 @@ import { */ export const FieldNameContainer = styled.span` border-radius: 4px; + display: flex; padding: 0 4px 0 8px; position: relative; diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx index 091bb41bc2080..65210ab2fd60a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx @@ -12,6 +12,7 @@ import { DraggableWrapper, } from '../../../common/components/drag_and_drop/draggable_wrapper'; import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { Content } from '../../../common/components/draggables'; import { getOrEmptyTagFromValue } from '../../../common/components/empty_value'; import { NetworkDetailsLink } from '../../../common/components/links'; import { parseQueryValue } from '../../../timelines/components/timeline/body/renderers/parse_query_value'; @@ -148,9 +149,11 @@ const AddressLinksItemComponent: React.FC = ({ ) : ( - + + + ), - [address, dataProviderProp] + [address, dataProviderProp, fieldName] ); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap index 4eb320571a75b..10ad0123f7fc6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap @@ -66,7 +66,8 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiButtonHeight": "40px", "euiButtonHeightSmall": "32px", "euiButtonIconTypes": Object { - "danger": "#ff6666", + "accent": "#f990c0", + "danger": "#ff7575", "disabled": "#4c4e51", "ghost": "#ffffff", "primary": "#1ba9f5", @@ -217,7 +218,7 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiDataGridColumnResizerWidth": "3px", "euiDataGridPopoverMaxHeight": "400px", "euiDataGridPrefix": ".euiDataGrid--", - "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'fontSizeSmall', 'fontSizeLarge', 'noControls'", + "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'footerShade', 'footerOverline', 'fontSizeSmall', 'fontSizeLarge', 'noControls', 'stickyFooter'", "euiDataGridVerticalBorder": "solid 1px #24272e", "euiDatePickerCalendarWidth": "284px", "euiDragAndDropSpacing": Object { @@ -292,9 +293,15 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiHeaderChildSize": "48px", "euiHeaderHeight": "48px", "euiHeaderHeightCompensation": "49px", + "euiHeaderLinksGutterSizes": Object { + "gutterL": "24px", + "gutterM": "12px", + "gutterS": "8px", + "gutterXS": "4px", + }, "euiIconColors": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "ghost": "#ffffff", "primary": "#1ba9f5", "secondary": "#7de2d1", diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index df5c48ad012a6..3f9d230575d46 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -95,9 +95,9 @@ export const EventColumnView = React.memo( toggleShowNotes, updateNote, }) => { - const { timelineType, status } = useShallowEqualSelector( - (state) => state.timeline.timelineById[timelineId] - ); + const { eventType: timelineEventType, timelineType, status } = useShallowEqualSelector< + TimelineModel + >((state) => state.timeline.timelineById[timelineId]); const handlePinClicked = useCallback( () => @@ -151,7 +151,7 @@ export const EventColumnView = React.memo( />, ] : []), - ...(eventType !== 'raw' + ...(timelineEventType !== 'raw' ? [ ( showNotes, status, timelineId, + timelineEventType, timelineType, toggleShowNotes, updateNote, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index ab9e47f5ae3f5..04709458a7428 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -54,6 +54,7 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} fieldName={fieldName} value={!isNumber(value) ? value : String(value)} + truncate={truncate} /> ); } else if (fieldType === DATE_FIELD_TYPE) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts index 92ebd9c2b0e36..ac927f60691e1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts @@ -33,6 +33,6 @@ export const NON_EXISTENT = i18n.translate('xpack.securitySolution.auditd.nonExi export const LINK_ELASTIC_ENDPOINT_SECURITY = i18n.translate( 'xpack.securitySolution.event.module.linkToElasticEndpointSecurityDescription', { - defaultMessage: 'Open in Elastic Endpoint Security', + defaultMessage: 'Open in Endpoint Security', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap index 5b14edf818fdc..2979de270fcc8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap @@ -72,13 +72,15 @@ exports[`Footer Timeline Component rendering it renders the default timeline foo data-test-subj="paging-control-container" grow={false} > - + + + { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); @@ -58,6 +59,7 @@ describe('Footer Timeline Component', () => { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); @@ -81,6 +83,7 @@ describe('Footer Timeline Component', () => { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); @@ -132,6 +135,7 @@ describe('Footer Timeline Component', () => { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); @@ -155,6 +159,7 @@ describe('Footer Timeline Component', () => { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); @@ -182,6 +187,7 @@ describe('Footer Timeline Component', () => { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); @@ -207,6 +213,7 @@ describe('Footer Timeline Component', () => { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); @@ -234,6 +241,7 @@ describe('Footer Timeline Component', () => { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); @@ -259,6 +267,7 @@ describe('Footer Timeline Component', () => { onChangePage={loadMore} serverSideEventCount={serverSideEventCount} totalCount={totalCount} + showMorePagesIndicator /> ); 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 7c10168da3c62..c06be6d50aae5 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 @@ -28,6 +28,7 @@ import { OnChangeItemsPerPage, OnChangePage } from '../events'; import { LastUpdatedAt } from './last_updated'; import * as i18n from './translations'; import { useEventDetailsWidthContext } from '../../../../common/components/events_viewer/event_details_width_context'; +import { PaginationEuiFlexItem } from '../../../../common/components/paginated_table'; import { useManageTimeline } from '../../manage_timeline'; export const isCompactFooter = (width: number): boolean => width < 600; @@ -223,6 +224,7 @@ interface FooterProps { onChangeItemsPerPage: OnChangeItemsPerPage; onChangePage: OnChangePage; serverSideEventCount: number; + showMorePagesIndicator: boolean; totalCount: number; } @@ -240,6 +242,7 @@ export const FooterComponent = ({ onChangeItemsPerPage, onChangePage, serverSideEventCount, + showMorePagesIndicator, totalCount, }: FooterProps) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -289,6 +292,11 @@ export const FooterComponent = ({ totalCount, ]); + const PaginationWrapper = useMemo( + () => (showMorePagesIndicator ? PaginationEuiFlexItem : EuiFlexItem), + [showMorePagesIndicator] + ); + useEffect(() => { if (paginationLoading && !isLoading) { setPaginationLoading(false); @@ -365,13 +373,15 @@ export const FooterComponent = ({ ) : ( - + + + )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 1097d58b227a8..abc5b1401d646 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -302,6 +302,7 @@ export const TimelineComponent: React.FC = ({ onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadPage} serverSideEventCount={totalCount} + showMorePagesIndicator={pageInfo.showMorePagesIndicator} totalCount={pageInfo.fakeTotalCount} /> diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index cd72ffb8ac803..2b3d615fe9b32 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -29,6 +29,8 @@ export interface UseTimelineEventsDetailsProps { skip: boolean; } +const ID = 'timelineEventsDetails'; + export const useTimelineEventsDetails = ({ docValueFields, indexName, @@ -49,7 +51,11 @@ export const useTimelineEventsDetails = ({ ); const timelineDetailsSearch = useCallback( - (request: TimelineEventsDetailsRequestOptions) => { + (request: TimelineEventsDetailsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -102,6 +108,7 @@ export const useTimelineEventsDetails = ({ ...(prevRequest ?? {}), docValueFields, indexName, + id: ID, eventId, factoryQueryType: TimelineEventsQueries.details, }; @@ -113,9 +120,7 @@ export const useTimelineEventsDetails = ({ }, [docValueFields, eventId, indexName, skip]); useEffect(() => { - if (timelineDetailsRequest) { - timelineDetailsSearch(timelineDetailsRequest); - } + timelineDetailsSearch(timelineDetailsRequest); }, [timelineDetailsRequest, timelineDetailsSearch]); return [loading, timelineDetailsResponse]; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 53944fd29a687..21e290e1ede82 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -90,7 +90,7 @@ export const useTimelineEvents = ({ fields, fieldRequested: fields, filterQuery: createFilter(filterQuery), - id, + id: ID, timerange: { interval: '12h', from: startDate, @@ -215,6 +215,7 @@ export const useTimelineEvents = ({ defaultIndex: indexNames, docValueFields: docValueFields ?? [], filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 6f24017b2274f..c057330507c02 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -12,13 +12,10 @@ import { isEsSearchResponse, isFieldHistogramsResponseSchema, } from '../../../common/api_schemas/type_guards'; - -import { getErrorMessage } from '../../../common/utils/errors'; - import type { EsSorting, UseIndexDataReturnType } from '../../shared_imports'; +import { getErrorMessage } from '../../../common/utils/errors'; import { isDefaultQuery, matchAllQuery, PivotQuery } from '../common'; - import { SearchItems } from './use_search_items'; import { useApi } from './use_api'; @@ -38,6 +35,7 @@ export const useIndexData = ( showDataGridColumnChartErrorMessageToast, useDataGrid, useRenderCellValue, + getProcessedFields, INDEX_STATUS, }, } = useAppDependencies(); @@ -86,6 +84,8 @@ export const useIndexData = ( const esSearchRequest = { index: indexPattern.title, body: { + fields: ['*'], + _source: false, // Instead of using the default query (`*`), fall back to a more efficient `match_all` query. query: isDefaultQuery(query) ? matchAllQuery : query, from: pagination.pageIndex * pagination.pageSize, @@ -102,7 +102,7 @@ export const useIndexData = ( return; } - const docs = resp.hits.hits.map((d) => d._source); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields)); setRowCount(resp.hits.total.value); setTableItems(docs); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 420df7618b934..2738398b3ebc4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10550,7 +10550,6 @@ "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTestingHelpText": "データセットをテストするための正規化された混同行列", "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTooltip": "マルチクラス混同行列には、分析が実際のクラスで正しくデータポイントを分類した発生数と、別のクラスで誤分類した発生数が含まれます。", "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTrainingHelpText": "データセットを学習するための正規化された混同行列", - "xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle": "分類ジョブID {jobId}の評価", "xpack.ml.dataframe.analytics.classificationExploration.generalizationDocsCount": "{docsCount, plural, one {# doc} other {# docs}}が評価されました", "xpack.ml.dataframe.analytics.classificationExploration.showActions": "アクションを表示", "xpack.ml.dataframe.analytics.classificationExploration.showAllColumns": "すべての列を表示", @@ -10753,13 +10752,11 @@ "xpack.ml.dataframe.analytics.errorCallout.queryParsingErrorTitle": "クエリをパースできません。", "xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle": "機能影響スコア", "xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText": "予測があるドキュメントを示す", - "xpack.ml.dataframe.analytics.explorationResults.fieldSelection": "{docFieldsCount, number}件中 showing {selectedFieldsLength, number}件の{docFieldsCount, plural, one {フィールド} other {フィールド}}", "xpack.ml.dataframe.analytics.explorationResults.firstDocumentsShownHelpText": "予測がある最初の{searchSize}のドキュメントを示す", "xpack.ml.dataframe.analytics.indexPatternPromptLinkText": "インデックスパターンを作成します", "xpack.ml.dataframe.analytics.indexPatternPromptMessage": "{destIndex}のインデックス{destIndex}. {linkToIndexPatternManagement}にはインデックスパターンが存在しません。", "xpack.ml.dataframe.analytics.jobCaps.errorTitle": "結果を取得できません。インデックスのフィールドデータの読み込み中にエラーが発生しました。", "xpack.ml.dataframe.analytics.jobConfig.errorTitle": "結果を取得できません。ジョブ構成データの読み込み中にエラーが発生しました。", - "xpack.ml.dataframe.analytics.regressionExploration.evaluateJobIdTitle": "回帰ジョブID {jobId}の評価", "xpack.ml.dataframe.analytics.regressionExploration.generalizationDocsCount": "{docsCount, plural, one {# doc} other {# docs}}が評価されました", "xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "一般化エラー", "xpack.ml.dataframe.analytics.regressionExploration.generalizationFilterText": ".学習データをフィルタリングしています。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a17be0f992b65..94fa87e8206c8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10556,7 +10556,6 @@ "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTestingHelpText": "用于测试数据集的标准化混淆矩阵", "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTooltip": "多类混淆矩阵包含分析使用数据点的实际类正确分类数据点的次数以及分析使用其他类错误分类这些数据点的次数", "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTrainingHelpText": "用于训练数据集的标准化混淆矩阵", - "xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle": "分类作业 ID {jobId} 的评估", "xpack.ml.dataframe.analytics.classificationExploration.generalizationDocsCount": "{docsCount, plural, one {# 个文档} other {# 个文档}}已评估", "xpack.ml.dataframe.analytics.classificationExploration.showActions": "显示操作", "xpack.ml.dataframe.analytics.classificationExploration.showAllColumns": "显示所有列", @@ -10759,13 +10758,11 @@ "xpack.ml.dataframe.analytics.errorCallout.queryParsingErrorTitle": "无法解析查询。", "xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle": "功能影响分数", "xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText": "正在显示有相关预测存在的文档", - "xpack.ml.dataframe.analytics.explorationResults.fieldSelection": "已选择 {docFieldsCount, number} 个{docFieldsCount, plural, one {字段} other {字段}}中的 {selectedFieldsLength, number} 个", "xpack.ml.dataframe.analytics.explorationResults.firstDocumentsShownHelpText": "正在显示有相关预测存在的前 {searchSize} 个文档", "xpack.ml.dataframe.analytics.indexPatternPromptLinkText": "创建索引模式", "xpack.ml.dataframe.analytics.indexPatternPromptMessage": "不存在索引 {destIndex} 的索引模式。{destIndex} 的{linkToIndexPatternManagement}。", "xpack.ml.dataframe.analytics.jobCaps.errorTitle": "无法提取结果。加载索引的字段数据时发生错误。", "xpack.ml.dataframe.analytics.jobConfig.errorTitle": "无法提取结果。加载作业配置数据时发生错误。", - "xpack.ml.dataframe.analytics.regressionExploration.evaluateJobIdTitle": "回归作业 ID {jobId} 的评估", "xpack.ml.dataframe.analytics.regressionExploration.generalizationDocsCount": "{docsCount, plural, one {# 个文档} other {# 个文档}}已评估", "xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "泛化误差", "xpack.ml.dataframe.analytics.regressionExploration.generalizationFilterText": ".筛留训练数据。", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/extract_action_variable.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/extract_action_variable.ts new file mode 100644 index 0000000000000..bb42951b9435d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/extract_action_variable.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fromNullable, Option } from 'fp-ts/lib/Option'; +import { ActionVariable } from '../../../types'; + +export function extractActionVariable( + actionVariables: ActionVariable[], + variableName: string +): Option { + return fromNullable(actionVariables?.find((variable) => variable.name === variableName)); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx index a0194ed5c81e4..0990baa30eb15 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx @@ -90,7 +90,7 @@ describe('JiraParamsFields renders', () => { errors={{ title: [] }} editAction={() => {}} index={0} - messageVariables={[]} + messageVariables={[{ name: 'alertId', description: '' }]} docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} toastNotifications={mocks.notifications.toasts} http={mocks.http} @@ -107,6 +107,27 @@ describe('JiraParamsFields renders', () => { expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="labelsComboBox"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); + + // ensure savedObjectIdInput isnt rendered + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); + }); + + test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + toastNotifications={mocks.notifications.toasts} + http={mocks.http} + actionConnector={connector} + /> + ); + + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); }); test('it shows loading when loading issue types', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index c19d2c4048665..880e39aada444 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -6,12 +6,20 @@ import React, { Fragment, useEffect, useState, useMemo } from 'react'; import { map } from 'lodash/fp'; -import { EuiFormRow, EuiComboBox, EuiSelectOption, EuiHorizontalRule } from '@elastic/eui'; +import { isSome } from 'fp-ts/lib/Option'; import { i18n } from '@kbn/i18n'; -import { EuiSelect } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiSpacer } from '@elastic/eui'; +import { + EuiFormRow, + EuiComboBox, + EuiSelectOption, + EuiHorizontalRule, + EuiSelect, + EuiFormControlLayout, + EuiIconTip, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; import { ActionParamsProps } from '../../../../types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; @@ -20,6 +28,7 @@ import { JiraActionParams } from './types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; +import { extractActionVariable } from '../extract_action_variable'; const JiraParamsFields: React.FunctionComponent> = ({ actionParams, @@ -38,6 +47,10 @@ const JiraParamsFields: React.FunctionComponent([]); + const isActionBeingConfiguredByAnAlert = messageVariables + ? isSome(extractActionVariable(messageVariables, 'alertId')) + : false; + useEffect(() => { setFirstLoad(true); }, []); @@ -127,7 +140,7 @@ const JiraParamsFields: React.FunctionComponent variable.name === 'alertId')) { + if (!savedObjectId && isActionBeingConfiguredByAnAlert) { editSubActionProperty('savedObjectId', '{{alertId}}'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -261,6 +274,45 @@ const JiraParamsFields: React.FunctionComponent + {!isActionBeingConfiguredByAnAlert && ( + + + + + } + > + + + + + + + )} {hasLabels && ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx index 5f03a548bf16e..a04e213e04872 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx @@ -86,7 +86,7 @@ describe('ResilientParamsFields renders', () => { errors={{ title: [] }} editAction={() => {}} index={0} - messageVariables={[]} + messageVariables={[{ name: 'alertId', description: '' }]} docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} toastNotifications={mocks.notifications.toasts} http={mocks.http} @@ -100,6 +100,27 @@ describe('ResilientParamsFields renders', () => { expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); + + // ensure savedObjectIdInput isnt rendered + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); + }); + + test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + toastNotifications={mocks.notifications.toasts} + http={mocks.http} + actionConnector={connector} + /> + ); + + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); }); test('it shows loading when loading incident types', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index b150c97506b69..996e83b87f059 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -13,8 +13,11 @@ import { EuiTitle, EuiComboBoxOptionOption, EuiSelectOption, + EuiFormControlLayout, + EuiIconTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { isSome } from 'fp-ts/lib/Option'; import { ActionParamsProps } from '../../../../types'; import { ResilientActionParams } from './types'; @@ -23,6 +26,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; +import { extractActionVariable } from '../extract_action_variable'; const ResilientParamsFields: React.FunctionComponent> = ({ actionParams, @@ -38,6 +42,10 @@ const ResilientParamsFields: React.FunctionComponent> >([]); @@ -98,7 +106,7 @@ const ResilientParamsFields: React.FunctionComponent variable.name === 'alertId')) { + if (!savedObjectId && isActionBeingConfiguredByAnAlert) { editSubActionProperty('savedObjectId', '{{alertId}}'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -218,6 +226,43 @@ const ResilientParamsFields: React.FunctionComponent + {!isActionBeingConfiguredByAnAlert && ( + + + + } + > + + + + + + )} { errors={{ title: [] }} editAction={() => {}} index={0} - messageVariables={[]} + messageVariables={[{ name: 'alertId', description: '' }]} docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} toastNotifications={mocks.notifications.toasts} http={mocks.http} @@ -46,5 +46,41 @@ describe('ServiceNowParamsFields renders', () => { expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentTextArea"]').length > 0).toBeTruthy(); + + // ensure savedObjectIdInput isnt rendered + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); + }); + + test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { + const mocks = coreMock.createSetup(); + const actionParams = { + subAction: 'pushToService', + subActionParams: { + title: 'sn title', + description: 'some description', + comment: 'comment for sn', + severity: '1', + urgency: '2', + impact: '3', + savedObjectId: '123', + externalId: null, + }, + }; + + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + toastNotifications={mocks.notifications.toasts} + http={mocks.http} + /> + ); + + // ensure savedObjectIdInput isnt rendered + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx index 2a2efdfbe35b1..3e59f2199153b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx @@ -5,23 +5,34 @@ */ import React, { Fragment, useEffect } from 'react'; -import { EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiSelect } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiSpacer } from '@elastic/eui'; -import { EuiTitle } from '@elastic/eui'; +import { + EuiFormRow, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiFormControlLayout, + EuiIconTip, +} from '@elastic/eui'; +import { isSome } from 'fp-ts/lib/Option'; import { ActionParamsProps } from '../../../../types'; import { ServiceNowActionParams } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; +import { extractActionVariable } from '../extract_action_variable'; const ServiceNowParamsFields: React.FunctionComponent> = ({ actionParams, editAction, index, errors, messageVariables }) => { const { title, description, comment, severity, urgency, impact, savedObjectId } = actionParams.subActionParams || {}; + + const isActionBeingConfiguredByAnAlert = messageVariables + ? isSome(extractActionVariable(messageVariables, 'alertId')) + : false; + const selectOptions = [ { value: '1', @@ -61,7 +72,7 @@ const ServiceNowParamsFields: React.FunctionComponent variable.name === 'alertId')) { + if (!savedObjectId && isActionBeingConfiguredByAnAlert) { editSubActionProperty('savedObjectId', '{{alertId}}'); } if (!urgency) { @@ -174,6 +185,43 @@ const ServiceNowParamsFields: React.FunctionComponent + {!isActionBeingConfiguredByAnAlert && ( + + + + } + > + + + + + + )} ; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${id}/_execute`, { + return http.post(`${BASE_ACTION_API_PATH}/action/${id}/_execute`, { body: JSON.stringify({ params }), }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 6c7a1cbdc3c70..4d8981f25aedc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -32,7 +32,10 @@ import { updateActionConnector, executeAction } from '../../lib/action_connector import { hasSaveActionsCapability } from '../../lib/capabilities'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { PLUGIN } from '../../constants/plugin'; -import { ActionTypeExecutorResult } from '../../../../../actions/common'; +import { + ActionTypeExecutorResult, + isActionTypeExecutorResult, +} from '../../../../../actions/common'; import './connector_edit_flyout.scss'; export interface ConnectorEditProps { @@ -204,13 +207,24 @@ export const ConnectorEditFlyout = ({ const onExecutAction = () => { setIsExecutinAction(true); - return executeAction({ id: connector.id, params: testExecutionActionParams, http }).then( - (result) => { + return executeAction({ id: connector.id, params: testExecutionActionParams, http }) + .then((result) => { setIsExecutinAction(false); setTestExecutionResult(some(result)); return result; - } - ); + }) + .catch((ex: Error | ActionTypeExecutorResult) => { + const result: ActionTypeExecutorResult = isActionTypeExecutorResult(ex) + ? ex + : { + actionId: connector.id, + status: 'error', + message: ex.message, + }; + setIsExecutinAction(false); + setTestExecutionResult(some(result)); + return result; + }); }; const onSaveClicked = async (closeAfterSave: boolean = true) => { diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx index e935798179402..7a705f03c0650 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx @@ -80,7 +80,7 @@ export interface ActionWizardProps< /** * List of possible triggers in current context */ - supportedTriggers: TriggerId[]; + triggers: TriggerId[]; triggerPickerDocsLink?: string; } @@ -94,7 +94,7 @@ export const ActionWizard: React.FC = ({ context, onSelectedTriggersChange, getTriggerInfo, - supportedTriggers, + triggers, triggerPickerDocsLink, }) => { // auto pick action factory if there is only 1 available @@ -108,14 +108,14 @@ export const ActionWizard: React.FC = ({ // auto pick selected trigger if none is picked if (currentActionFactory && !((context.triggers?.length ?? 0) > 0)) { - const triggers = getTriggersForActionFactory(currentActionFactory, supportedTriggers); - if (triggers.length > 0) { - onSelectedTriggersChange([triggers[0]]); + const actionTriggers = getTriggersForActionFactory(currentActionFactory, triggers); + if (actionTriggers.length > 0) { + onSelectedTriggersChange([actionTriggers[0]]); } } if (currentActionFactory && config) { - const allTriggers = getTriggersForActionFactory(currentActionFactory, supportedTriggers); + const allTriggers = getTriggersForActionFactory(currentActionFactory, triggers); return (

diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.stories.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.stories.tsx index daa56354289cf..a909d2a27f454 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.stories.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.stories.tsx @@ -34,7 +34,7 @@ storiesOf('components/FlyoutManageDrilldowns', module) {}}> )) @@ -42,7 +42,7 @@ storiesOf('components/FlyoutManageDrilldowns', module) {}}> )); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx index 48dbd5a864170..bef6834ed4c47 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -19,7 +19,7 @@ import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { NotificationsStart } from 'kibana/public'; -import { toastDrilldownsCRUDError } from './i18n'; +import { toastDrilldownsCRUDError } from '../../hooks/i18n'; const storage = new Storage(new StubBrowserStorage()); const toasts = coreMock.createStart().notifications.toasts; @@ -41,7 +41,7 @@ test('Allows to manage drilldowns', async () => { const screen = render( ); @@ -115,7 +115,7 @@ test('Can delete multiple drilldowns', async () => { const screen = render( ); // wait for initial render. It is async because resolving compatible action factories is async @@ -157,7 +157,7 @@ test('Create only mode', async () => { dynamicActionManager={mockDynamicActionManager} viewMode={'create'} onClose={onClose} - supportedTriggers={mockSupportedTriggers} + triggers={mockSupportedTriggers} /> ); // wait for initial render. It is async because resolving compatible action factories is async @@ -181,7 +181,7 @@ test('After switching between action factories state is restored', async () => { ); // wait for initial render. It is async because resolving compatible action factories is async @@ -222,7 +222,7 @@ test("Error when can't save drilldown changes", async () => { const screen = render( ); // wait for initial render. It is async because resolving compatible action factories is async @@ -245,7 +245,7 @@ test('Should show drilldown welcome message. Should be able to dismiss it', asyn let screen = render( ); @@ -260,7 +260,7 @@ test('Should show drilldown welcome message. Should be able to dismiss it', asyn screen = render( ); // wait for initial render. It is async because resolving compatible action factories is async @@ -272,7 +272,7 @@ test('Drilldown type is not shown if no supported trigger', async () => { const screen = render( ); @@ -286,7 +286,7 @@ test('Can pick a trigger', async () => { const screen = render( ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx index 272ec3edc9d29..1f148de7b5178 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -4,33 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import { ToastsStart } from 'kibana/public'; -import useMountedState from 'react-use/lib/useMountedState'; import { intersection } from 'lodash'; import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; -import { useContainerState } from '../../../../../../../src/plugins/kibana_utils/public'; import { DrilldownListItem } from '../list_manage_drilldowns'; -import { - insufficientLicenseLevel, - invalidDrilldownType, - toastDrilldownCreated, - toastDrilldownDeleted, - toastDrilldownEdited, - toastDrilldownsCRUDError, - toastDrilldownsDeleted, -} from './i18n'; +import { insufficientLicenseLevel, invalidDrilldownType } from './i18n'; import { ActionFactory, BaseActionConfig, BaseActionFactoryContext, DynamicActionManager, - SerializedAction, SerializedEvent, } from '../../../dynamic_actions'; +import { useWelcomeMessage } from '../../hooks/use_welcome_message'; +import { useCompatibleActionFactoriesForCurrentContext } from '../../hooks/use_compatible_action_factories_for_current_context'; +import { useDrilldownsStateManager } from '../../hooks/use_drilldown_state_manager'; import { ActionFactoryPlaceContext } from '../types'; interface ConnectedFlyoutManageDrilldownsProps< @@ -43,7 +35,7 @@ interface ConnectedFlyoutManageDrilldownsProps< /** * List of possible triggers in current context */ - supportedTriggers: TriggerId[]; + triggers: TriggerId[]; /** * Extra action factory context passed into action factories CollectConfig, getIconType, getDisplayName and etc... @@ -74,7 +66,7 @@ export function createFlyoutManageDrilldowns({ toastService: ToastsStart; docsLink?: string; triggerPickerDocsLink?: string; -}) { +}): React.FC { const allActionFactoriesById = allActionFactories.reduce((acc, next) => { acc[next.id] = next; return acc; @@ -84,8 +76,8 @@ export function createFlyoutManageDrilldowns({ const isCreateOnly = props.viewMode === 'create'; const factoryContext: BaseActionFactoryContext = useMemo( - () => ({ ...props.placeContext, triggers: props.supportedTriggers }), - [props.placeContext, props.supportedTriggers] + () => ({ ...props.placeContext, triggers: props.triggers }), + [props.placeContext, props.triggers] ); const actionFactories = useCompatibleActionFactoriesForCurrentContext( allActionFactories, @@ -210,7 +202,7 @@ export function createFlyoutManageDrilldowns({ }} actionFactoryPlaceContext={props.placeContext} initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} - supportedTriggers={props.supportedTriggers} + supportedTriggers={props.triggers} getTrigger={getTrigger} /> ); @@ -220,7 +212,7 @@ export function createFlyoutManageDrilldowns({ // show trigger column in case if there is more then 1 possible trigger in current context const showTriggerColumn = intersection( - props.supportedTriggers, + props.triggers, actionFactories .map((factory) => factory.supportedTriggers()) .reduce((res, next) => res.concat(next), []) @@ -250,108 +242,3 @@ export function createFlyoutManageDrilldowns({ } }; } - -function useCompatibleActionFactoriesForCurrentContext< - Context extends BaseActionFactoryContext = BaseActionFactoryContext ->(actionFactories: ActionFactory[], context: Context) { - const [compatibleActionFactories, setCompatibleActionFactories] = useState(); - useEffect(() => { - let canceled = false; - async function updateCompatibleFactoriesForContext() { - const compatibility = await Promise.all( - actionFactories.map((factory) => factory.isCompatible(context)) - ); - if (canceled) return; - - const compatibleFactories = actionFactories.filter((_, i) => compatibility[i]); - const triggerSupportedFactories = compatibleFactories.filter((factory) => - factory.supportedTriggers().some((trigger) => context.triggers.includes(trigger)) - ); - setCompatibleActionFactories(triggerSupportedFactories); - } - updateCompatibleFactoriesForContext(); - return () => { - canceled = true; - }; - }, [context, actionFactories, context.triggers]); - - return compatibleActionFactories; -} - -function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { - const key = `drilldowns:hidWelcomeMessage`; - const [hidWelcomeMessage, setHidWelcomeMessage] = useState(storage.get(key) ?? false); - - return [ - !hidWelcomeMessage, - () => { - if (hidWelcomeMessage) return; - setHidWelcomeMessage(true); - storage.set(key, true); - }, - ]; -} - -function useDrilldownsStateManager(actionManager: DynamicActionManager, toastService: ToastsStart) { - const { events: drilldowns } = useContainerState(actionManager.state); - const [isLoading, setIsLoading] = useState(false); - const isMounted = useMountedState(); - - async function run(op: () => Promise) { - setIsLoading(true); - try { - await op(); - } catch (e) { - toastService.addError(e, { - title: toastDrilldownsCRUDError, - }); - if (!isMounted) return; - setIsLoading(false); - return; - } - } - - async function createDrilldown(action: SerializedAction, selectedTriggers: TriggerId[]) { - await run(async () => { - await actionManager.createEvent(action, selectedTriggers); - toastService.addSuccess({ - title: toastDrilldownCreated.title(action.name), - text: toastDrilldownCreated.text, - }); - }); - } - - async function editDrilldown( - drilldownId: string, - action: SerializedAction, - selectedTriggers: TriggerId[] - ) { - await run(async () => { - await actionManager.updateEvent(drilldownId, action, selectedTriggers); - toastService.addSuccess({ - title: toastDrilldownEdited.title(action.name), - text: toastDrilldownEdited.text, - }); - }); - } - - async function deleteDrilldown(drilldownIds: string | string[]) { - await run(async () => { - drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; - await actionManager.deleteEvents(drilldownIds); - toastService.addSuccess( - drilldownIds.length === 1 - ? { - title: toastDrilldownDeleted.title, - text: toastDrilldownDeleted.text, - } - : { - title: toastDrilldownsDeleted.title(drilldownIds.length), - text: toastDrilldownsDeleted.text, - } - ); - }); - } - - return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; -} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts index 4b2be5db0c558..b684189a60fee 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts @@ -6,87 +6,6 @@ import { i18n } from '@kbn/i18n'; -export const toastDrilldownCreated = { - title: (drilldownName: string) => - i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', - { - defaultMessage: 'Drilldown "{drilldownName}" created', - values: { - drilldownName, - }, - } - ), - text: i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', - { - // TODO: remove `Save your dashboard before testing.` part - // when drilldowns are used not only in dashboard - // or after https://github.com/elastic/kibana/issues/65179 implemented - defaultMessage: 'Save your dashboard before testing.', - } - ), -}; - -export const toastDrilldownEdited = { - title: (drilldownName: string) => - i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', - { - defaultMessage: 'Drilldown "{drilldownName}" updated', - values: { - drilldownName, - }, - } - ), - text: i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', - { - defaultMessage: 'Save your dashboard before testing.', - } - ), -}; - -export const toastDrilldownDeleted = { - title: i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', - { - defaultMessage: 'Drilldown deleted', - } - ), - text: i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', - { - defaultMessage: 'Save your dashboard before testing.', - } - ), -}; - -export const toastDrilldownsDeleted = { - title: (n: number) => - i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', - { - defaultMessage: '{n} drilldowns deleted', - values: { n }, - } - ), - text: i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', - { - defaultMessage: 'Save your dashboard before testing.', - } - ), -}; - -export const toastDrilldownsCRUDError = i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', - { - defaultMessage: 'Error saving drilldown', - description: 'Title for generic error toast when persisting drilldown updates failed', - } -); - export const insufficientLicenseLevel = i18n.translate( 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError', { diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx index c67a6bbdd30b6..d54bfe0af3b8b 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -230,7 +230,7 @@ export function FlyoutDrilldownWizard< actionFactories={drilldownActionFactories} actionFactoryContext={actionFactoryContext} onSelectedTriggersChange={setSelectedTriggers} - supportedTriggers={supportedTriggers} + triggers={supportedTriggers} getTriggerInfo={getTrigger} triggerPickerDocsLink={triggerPickerDocsLink} /> diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.stories.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.stories.tsx index 9ab893f23b398..386ec0fb0e62b 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.stories.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.stories.tsx @@ -10,11 +10,7 @@ import { FormDrilldownWizard } from './index'; import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; const otherProps = { - supportedTriggers: [ - 'VALUE_CLICK_TRIGGER', - 'SELECT_RANGE_TRIGGER', - 'FILTER_TRIGGER', - ] as TriggerId[], + triggers: ['VALUE_CLICK_TRIGGER', 'SELECT_RANGE_TRIGGER', 'FILTER_TRIGGER'] as TriggerId[], getTriggerInfo: (id: TriggerId) => ({ id } as Trigger), onSelectedTriggersChange: () => {}, actionFactoryContext: { triggers: [] as TriggerId[] }, diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx index 614679ed02a41..35a897913b537 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx @@ -13,11 +13,7 @@ import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/ const otherProps = { actionFactoryContext: { triggers: [] as TriggerId[] }, - supportedTriggers: [ - 'VALUE_CLICK_TRIGGER', - 'SELECT_RANGE_TRIGGER', - 'FILTER_TRIGGER', - ] as TriggerId[], + triggers: ['VALUE_CLICK_TRIGGER', 'SELECT_RANGE_TRIGGER', 'FILTER_TRIGGER'] as TriggerId[], getTriggerInfo: (id: TriggerId) => ({ id } as Trigger), onSelectedTriggersChange: () => {}, }; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx index 45655c2634fe7..5f5b577706cf9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -6,7 +6,8 @@ import React from 'react'; import { EuiFieldText, EuiForm, EuiFormRow, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut } from '@elastic/eui'; +import { EuiCode } from '@elastic/eui'; import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; import { ActionFactory, @@ -15,6 +16,7 @@ import { } from '../../../dynamic_actions'; import { ActionWizard } from '../../../components/action_wizard'; import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; +import { txtGetMoreActions } from './i18n'; const GET_MORE_ACTIONS_LINK = 'https://www.elastic.co/subscriptions'; @@ -46,7 +48,7 @@ export interface FormDrilldownWizardProps< /** * List of possible triggers in current context */ - supportedTriggers: TriggerId[]; + triggers: TriggerId[]; triggerPickerDocsLink?: string; } @@ -62,9 +64,20 @@ export const FormDrilldownWizard: React.FC = ({ actionFactoryContext, onSelectedTriggersChange, getTriggerInfo, - supportedTriggers, + triggers, triggerPickerDocsLink, }) => { + if (!triggers || !triggers.length) { + // Below callout is not translated, because this message is only for developers. + return ( + +

+ No triggers provided in trigger prop. +

+
+ ); + } + const nameFragment = ( = ({ external data-test-subj={'getMoreActionsLink'} > - + {txtGetMoreActions} ); @@ -114,7 +124,7 @@ export const FormDrilldownWizard: React.FC = ({ context={actionFactoryContext} onSelectedTriggersChange={onSelectedTriggersChange} getTriggerInfo={getTriggerInfo} - supportedTriggers={supportedTriggers} + triggers={triggers} triggerPickerDocsLink={triggerPickerDocsLink} /> diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/i18n.ts index 9636b6e8a74e7..bf0a012f559f8 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/i18n.ts @@ -26,3 +26,10 @@ export const txtDrilldownAction = i18n.translate( defaultMessage: 'Action', } ); + +export const txtGetMoreActions = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.FormDrilldownWizard.getMoreActionsLinkLabel', + { + defaultMessage: 'Get more actions', + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/i18n.ts new file mode 100644 index 0000000000000..e75ee2634aa43 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/i18n.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const toastDrilldownCreated = { + title: (drilldownName: string) => + i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', + { + defaultMessage: 'Drilldown "{drilldownName}" created', + values: { + drilldownName, + }, + } + ), + text: i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', + { + // TODO: remove `Save your dashboard before testing.` part + // when drilldowns are used not only in dashboard + // or after https://github.com/elastic/kibana/issues/65179 implemented + defaultMessage: 'Save your dashboard before testing.', + } + ), +}; + +export const toastDrilldownEdited = { + title: (drilldownName: string) => + i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', + { + defaultMessage: 'Drilldown "{drilldownName}" updated', + values: { + drilldownName, + }, + } + ), + text: i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', + { + defaultMessage: 'Save your dashboard before testing.', + } + ), +}; + +export const toastDrilldownDeleted = { + title: i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', + { + defaultMessage: 'Drilldown deleted', + } + ), + text: i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', + { + defaultMessage: 'Save your dashboard before testing.', + } + ), +}; + +export const toastDrilldownsDeleted = { + title: (n: number) => + i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', + { + defaultMessage: '{n} drilldowns deleted', + values: { n }, + } + ), + text: i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', + { + defaultMessage: 'Save your dashboard before testing.', + } + ), +}; + +export const toastDrilldownsCRUDError = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', + { + defaultMessage: 'Error saving drilldown', + description: 'Title for generic error toast when persisting drilldown updates failed', + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_compatible_action_factories_for_current_context.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_compatible_action_factories_for_current_context.ts new file mode 100644 index 0000000000000..d99889045d469 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_compatible_action_factories_for_current_context.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState } from 'react'; +import { ActionFactory, BaseActionFactoryContext } from '../../dynamic_actions'; + +export function useCompatibleActionFactoriesForCurrentContext< + Context extends BaseActionFactoryContext = BaseActionFactoryContext +>(actionFactories: ActionFactory[], context: Context) { + const [compatibleActionFactories, setCompatibleActionFactories] = useState(); + useEffect(() => { + let canceled = false; + async function updateCompatibleFactoriesForContext() { + const compatibility = await Promise.all( + actionFactories.map((factory) => factory.isCompatible(context)) + ); + if (canceled) return; + + const compatibleFactories = actionFactories.filter((_, i) => compatibility[i]); + const triggerSupportedFactories = compatibleFactories.filter((factory) => + factory.supportedTriggers().some((trigger) => context.triggers.includes(trigger)) + ); + setCompatibleActionFactories(triggerSupportedFactories); + } + updateCompatibleFactoriesForContext(); + return () => { + canceled = true; + }; + }, [context, actionFactories, context.triggers]); + + return compatibleActionFactories; +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_drilldown_state_manager.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_drilldown_state_manager.tsx new file mode 100644 index 0000000000000..b578e36ba0606 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_drilldown_state_manager.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; +import { ToastsStart } from 'kibana/public'; +import useMountedState from 'react-use/lib/useMountedState'; +import { TriggerId } from '../../../../../../src/plugins/ui_actions/public'; +import { useContainerState } from '../../../../../../src/plugins/kibana_utils/public'; +import { + toastDrilldownCreated, + toastDrilldownDeleted, + toastDrilldownEdited, + toastDrilldownsCRUDError, + toastDrilldownsDeleted, +} from './i18n'; +import { DynamicActionManager, SerializedAction } from '../../dynamic_actions'; + +export function useDrilldownsStateManager( + actionManager: DynamicActionManager, + toastService: ToastsStart +) { + const { events: drilldowns } = useContainerState(actionManager.state); + const [isLoading, setIsLoading] = useState(false); + const isMounted = useMountedState(); + + async function run(op: () => Promise) { + setIsLoading(true); + try { + await op(); + } catch (e) { + toastService.addError(e, { + title: toastDrilldownsCRUDError, + }); + if (!isMounted) return; + setIsLoading(false); + return; + } + } + + async function createDrilldown(action: SerializedAction, selectedTriggers: TriggerId[]) { + await run(async () => { + await actionManager.createEvent(action, selectedTriggers); + toastService.addSuccess({ + title: toastDrilldownCreated.title(action.name), + text: toastDrilldownCreated.text, + }); + }); + } + + async function editDrilldown( + drilldownId: string, + action: SerializedAction, + selectedTriggers: TriggerId[] + ) { + await run(async () => { + await actionManager.updateEvent(drilldownId, action, selectedTriggers); + toastService.addSuccess({ + title: toastDrilldownEdited.title(action.name), + text: toastDrilldownEdited.text, + }); + }); + } + + async function deleteDrilldown(drilldownIds: string | string[]) { + await run(async () => { + drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; + await actionManager.deleteEvents(drilldownIds); + toastService.addSuccess( + drilldownIds.length === 1 + ? { + title: toastDrilldownDeleted.title, + text: toastDrilldownDeleted.text, + } + : { + title: toastDrilldownsDeleted.title(drilldownIds.length), + text: toastDrilldownsDeleted.text, + } + ); + }); + } + + return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_welcome_message.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_welcome_message.ts new file mode 100644 index 0000000000000..89c9445b09a4b --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/hooks/use_welcome_message.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; +import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; + +export function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { + const key = `drilldowns:hidWelcomeMessage`; + const [hideWelcomeMessage, setHideWelcomeMessage] = useState(storage.get(key) ?? false); + + return [ + !hideWelcomeMessage, + () => { + if (hideWelcomeMessage) return; + setHideWelcomeMessage(true); + storage.set(key, true); + }, + ]; +} diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts index 67b13d70fa3ee..d32c47bb5d3f9 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts @@ -66,6 +66,7 @@ export const MonitorSummaryType = t.intersection([ }), t.partial({ histogram: HistogramType, + minInterval: t.number, }), ]); diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts b/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts index 47e4dd52299b1..f19b147a371a3 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts @@ -26,7 +26,7 @@ export interface GetPingHistogramParams { export interface HistogramResult { histogram: HistogramDataPoint[]; - interval: string; + minInterval: number; } export interface HistogramQueryResult { diff --git a/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/monitor_bar_series.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/monitor_bar_series.test.tsx.snap index 7316cfa368c6e..d4025d62c3678 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/monitor_bar_series.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/monitor_bar_series.test.tsx.snap @@ -131,6 +131,7 @@ exports[`MonitorBarSeries component shallow renders a series when there are down }, ] } + minInterval={10} /> diff --git a/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/ping_histogram.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/ping_histogram.test.tsx.snap index 7fdb2e4ede75b..40abce98f5f33 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/ping_histogram.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/ping_histogram.test.tsx.snap @@ -273,7 +273,7 @@ exports[`PingHistogram component shallow renders the component without errors 1` "y": 1, }, ], - "interval": "1s", + "minInterval": 60, } } /> diff --git a/x-pack/plugins/uptime/public/components/common/charts/__tests__/monitor_bar_series.test.tsx b/x-pack/plugins/uptime/public/components/common/charts/__tests__/monitor_bar_series.test.tsx index 5e49d303c5c66..0090a8c5f170b 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__tests__/monitor_bar_series.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/__tests__/monitor_bar_series.test.tsx @@ -31,6 +31,7 @@ describe('MonitorBarSeries component', () => { up: 0, }, ], + minInterval: 10, }; histogramSeries = [ { timestamp: 1580387868000, up: 0, down: 5 }, @@ -192,14 +193,16 @@ describe('MonitorBarSeries component', () => { }); it('shallow renders nothing if the data series is null', () => { - const component = shallowWithRouter(); + const component = shallowWithRouter( + + ); expect(component).toEqual({}); }); it('renders if the data series is present', () => { const component = renderWithRouter( - + ); expect(component).toMatchSnapshot(); diff --git a/x-pack/plugins/uptime/public/components/common/charts/__tests__/ping_histogram.test.tsx b/x-pack/plugins/uptime/public/components/common/charts/__tests__/ping_histogram.test.tsx index 73c6ee43ccd07..fe14afbcdcfe4 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__tests__/ping_histogram.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/__tests__/ping_histogram.test.tsx @@ -44,7 +44,7 @@ describe('PingHistogram component', () => { { x: 1581068989000, downCount: 3, upCount: 36, y: 1 }, { x: 1581069019000, downCount: 1, upCount: 11, y: 1 }, ], - interval: '1s', + minInterval: 60, }, }; diff --git a/x-pack/plugins/uptime/public/components/common/charts/__tests__/utils.test.ts b/x-pack/plugins/uptime/public/components/common/charts/__tests__/utils.test.ts new file mode 100644 index 0000000000000..45bb0538e900c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/charts/__tests__/utils.test.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getDateRangeFromChartElement } from '../utils'; +import { XYChartElementEvent } from '@elastic/charts'; + +describe('Chart utils', () => { + it('get date range from chart element should add 100 miliseconds', () => { + const elementData = [{ x: 1548697920000, y: 4 }]; + const dr = getDateRangeFromChartElement(elementData as XYChartElementEvent, 1000); + expect(dr).toStrictEqual({ + dateRangeStart: '2019-01-28T17:52:00.000Z', + dateRangeEnd: '2019-01-28T17:52:01.000Z', + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/common/charts/monitor_bar_series.tsx b/x-pack/plugins/uptime/public/components/common/charts/monitor_bar_series.tsx index 0f01ef0e79931..fda76594e8826 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/monitor_bar_series.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/monitor_bar_series.tsx @@ -13,6 +13,8 @@ import { Position, timeFormatter, BrushEndListener, + XYChartElementEvent, + ElementClickListener, } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; @@ -23,12 +25,15 @@ import { HistogramPoint } from '../../../../common/runtime_types'; import { getChartDateLabel, seriesHasDownValues } from '../../../lib/helper'; import { useUrlParams } from '../../../hooks'; import { UptimeThemeContext } from '../../../contexts'; +import { getDateRangeFromChartElement } from './utils'; export interface MonitorBarSeriesProps { /** * The timeseries data to display. */ histogramSeries: HistogramPoint[] | null; + + minInterval: number; } /** @@ -36,7 +41,7 @@ export interface MonitorBarSeriesProps { * so we will only render the series component if there are down counts for the selected monitor. * @param props - the values for the monitor this chart visualizes */ -export const MonitorBarSeries = ({ histogramSeries }: MonitorBarSeriesProps) => { +export const MonitorBarSeries = ({ histogramSeries, minInterval }: MonitorBarSeriesProps) => { const { colors: { danger }, chartTheme, @@ -55,14 +60,23 @@ export const MonitorBarSeries = ({ histogramSeries }: MonitorBarSeriesProps) => }); }; + const onBarClicked: ElementClickListener = ([elementData]) => { + updateUrlParams(getDateRangeFromChartElement(elementData as XYChartElementEvent, minInterval)); + }; + const id = 'downSeries'; return seriesHasDownValues(histogramSeries) ? (
= ({ /> ); } else { - const { histogram } = data; + const { histogram, minInterval } = data; const downSpecId = i18n.translate('xpack.uptime.snapshotHistogram.series.downLabel', { defaultMessage: 'Down', @@ -100,6 +103,12 @@ export const PingHistogramComponent: React.FC = ({ }); }; + const onBarClicked: ElementClickListener = ([elementData]) => { + updateUrlParams( + getDateRangeFromChartElement(elementData as XYChartElementEvent, minInterval) + ); + }; + const barData: BarPoint[] = []; histogram.forEach(({ x, upCount, downCount }) => { @@ -125,11 +134,13 @@ export const PingHistogramComponent: React.FC = ({ { + const startRange = (elementData as XYChartElementEvent)[0].x; + + return { + dateRangeStart: moment(startRange).toISOString(), + dateRangeEnd: moment(startRange).add(minInterval, 'ms').toISOString(), + }; +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index 718e9e9948081..5e0cc5d3dee1d 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -139,8 +139,8 @@ export const MonitorListComponent: ({ mobileOptions: { show: false, }, - render: (histogramSeries: HistogramPoint[] | null) => ( - + render: (histogramSeries: HistogramPoint[] | null, summary: MonitorSummary) => ( + ), }, { diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_ping_histogram.test.ts.snap b/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_ping_histogram.test.ts.snap index 37dec410664ef..774ae47d68acd 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_ping_histogram.test.ts.snap +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/__snapshots__/get_ping_histogram.test.ts.snap @@ -22,7 +22,7 @@ Object { "y": 1, }, ], - "interval": "1m", + "minInterval": 36000, } `; @@ -48,7 +48,7 @@ Object { "y": 1, }, ], - "interval": "1h", + "minInterval": 36000, } `; @@ -62,7 +62,7 @@ Object { "y": 1, }, ], - "interval": "10s", + "minInterval": 36000, } `; @@ -82,6 +82,6 @@ Object { "y": 1, }, ], - "interval": "1m", + "minInterval": 36000, } `; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts index 11c7511dec370..0ae5887b31a7b 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts @@ -140,8 +140,8 @@ describe('getPingHistogram', () => { const result = await getPingHistogram({ callES: mockEsClient, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, - from: '1234', - to: '5678', + from: 'now-15m', + to: 'now', filters: JSON.stringify(searchFilter), monitorId: undefined, }); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts index 4aff852d7c953..3e49a32881f54 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts @@ -68,13 +68,21 @@ export const getMonitorStates: UMElasticsearchQueryFn< const iterator = new MonitorSummaryIterator(queryContext); const page = await iterator.nextPage(size); + const minInterval = getHistogramInterval( + queryContext.dateRangeStart, + queryContext.dateRangeEnd, + 12 + ); + const histograms = await getHistogramForMonitors( queryContext, - page.monitorSummaries.map((s) => s.monitor_id) + page.monitorSummaries.map((s) => s.monitor_id), + minInterval ); page.monitorSummaries.forEach((s) => { s.histogram = histograms[s.monitor_id]; + s.minInterval = minInterval; }); return { @@ -86,7 +94,8 @@ export const getMonitorStates: UMElasticsearchQueryFn< export const getHistogramForMonitors = async ( queryContext: QueryContext, - monitorIds: string[] + monitorIds: string[], + minInterval: number ): Promise<{ [key: string]: Histogram }> => { const params = { index: queryContext.heartbeatIndices, @@ -122,9 +131,7 @@ export const getHistogramForMonitors = async ( field: '@timestamp', // 12 seems to be a good size for performance given // long monitor lists of up to 100 on the overview page - fixed_interval: - getHistogramInterval(queryContext.dateRangeStart, queryContext.dateRangeEnd, 12) + - 'ms', + fixed_interval: minInterval + 'ms', missing: 0, }, aggs: { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index 970d9ad166982..5d8706e2fc5f1 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -37,6 +37,8 @@ export const getPingHistogram: UMElasticsearchQueryFn< } const filter = getFilterClause(from, to, additionalFilters); + const minInterval = getHistogramInterval(from, to, QUERY.DEFAULT_BUCKET_COUNT); + const params = { index: dynamicSettings.heartbeatIndices, body: { @@ -50,8 +52,7 @@ export const getPingHistogram: UMElasticsearchQueryFn< timeseries: { date_histogram: { field: '@timestamp', - fixed_interval: - bucketSize || getHistogramInterval(from, to, QUERY.DEFAULT_BUCKET_COUNT) + 'ms', + fixed_interval: bucketSize || minInterval + 'ms', missing: 0, }, aggs: { @@ -76,7 +77,6 @@ export const getPingHistogram: UMElasticsearchQueryFn< }; const result = await callES('search', params); - const interval = result.aggregations?.timeseries?.interval; const buckets: HistogramQueryResult[] = result?.aggregations?.timeseries?.buckets ?? []; const histogram = buckets.map((bucket) => { const x: number = bucket.key; @@ -91,6 +91,6 @@ export const getPingHistogram: UMElasticsearchQueryFn< }); return { histogram, - interval, + minInterval, }; }; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 6271c4b601307..6c0edd904b0e7 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -31,16 +31,16 @@ const onlyNotInCoverageTests = [ require.resolve('../test/plugin_api_integration/config.ts'), require.resolve('../test/kerberos_api_integration/config.ts'), require.resolve('../test/kerberos_api_integration/anonymous_access.config.ts'), - require.resolve('../test/saml_api_integration/config.ts'), + require.resolve('../test/security_api_integration/saml.config.ts'), require.resolve('../test/security_api_integration/session_idle.config.ts'), require.resolve('../test/security_api_integration/session_lifespan.config.ts'), + require.resolve('../test/security_api_integration/login_selector.config.ts'), require.resolve('../test/token_api_integration/config.js'), require.resolve('../test/oidc_api_integration/config.ts'), require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), require.resolve('../test/observability_api_integration/basic/config.ts'), require.resolve('../test/observability_api_integration/trial/config.ts'), require.resolve('../test/pki_api_integration/config.ts'), - require.resolve('../test/login_selector_api_integration/config.ts'), require.resolve('../test/encrypted_saved_objects_api_integration/config.ts'), require.resolve('../test/spaces_api_integration/spaces_only/config.ts'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial.ts'), diff --git a/x-pack/tasks/build.ts b/x-pack/tasks/build.ts index a3b08a16f4b08..aaeea9d14e385 100644 --- a/x-pack/tasks/build.ts +++ b/x-pack/tasks/build.ts @@ -73,7 +73,7 @@ async function copySourceAndBabelify() { '**/*.{test,test.mocks,mock,mocks}.*', '**/*.d.ts', '**/node_modules/**', - '**/public/**', + '**/public/**/*.{js,ts,tsx,json}', '**/{__tests__,__mocks__,__snapshots__}/**', 'plugins/canvas/shareable_runtime/test/**', ], diff --git a/x-pack/test/accessibility/apps/users.ts b/x-pack/test/accessibility/apps/users.ts new file mode 100644 index 0000000000000..b3426410962af --- /dev/null +++ b/x-pack/test/accessibility/apps/users.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// a11y tests for spaces, space selection and spacce creation and feature controls + +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['security', 'settings']); + const a11y = getService('a11y'); + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + + describe('Kibana users page a11y tests', () => { + before(async () => { + await esArchiver.load('empty_kibana'); + await PageObjects.security.clickElasticsearchUsers(); + }); + + it('a11y test for user page', async () => { + await a11y.testAppSnapshot(); + }); + + it('a11y test for search user bar', async () => { + await testSubjects.click('searchUsers'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for searching a user', async () => { + await testSubjects.setValue('searchUsers', 'test'); + await a11y.testAppSnapshot(); + await testSubjects.setValue('searchUsers', ''); + }); + + it('a11y test for toggle button for show reserved users only', async () => { + await retry.waitFor( + 'show reserved users toggle button is visible', + async () => await testSubjects.exists('showReservedUsersSwitch') + ); + await testSubjects.click('showReservedUsersSwitch'); + await a11y.testAppSnapshot(); + await testSubjects.click('showReservedUsersSwitch'); + }); + + it('a11y test for toggle button for show reserved users only', async () => { + await retry.waitFor( + 'show reserved users toggle button is visible', + async () => await testSubjects.exists('showReservedUsersSwitch') + ); + await testSubjects.click('showReservedUsersSwitch'); + await a11y.testAppSnapshot(); + await testSubjects.click('showReservedUsersSwitch'); + }); + + it('a11y test for create user panel', async () => { + await testSubjects.click('createUserButton'); + await a11y.testAppSnapshot(); + }); + + // https://github.com/elastic/eui/issues/2841 + it.skip('a11y test for roles drop down', async () => { + await testSubjects.setValue('userFormUserNameInput', 'a11y'); + await testSubjects.setValue('passwordInput', 'password'); + await testSubjects.setValue('passwordConfirmationInput', 'password'); + await testSubjects.setValue('userFormFullNameInput', 'a11y user'); + await testSubjects.setValue('userFormEmailInput', 'example@example.com'); + await testSubjects.click('rolesDropdown'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for display of delete button on users page ', async () => { + await testSubjects.setValue('userFormUserNameInput', 'deleteA11y'); + await testSubjects.setValue('passwordInput', 'password'); + await testSubjects.setValue('passwordConfirmationInput', 'password'); + await testSubjects.setValue('userFormFullNameInput', 'DeleteA11y user'); + await testSubjects.setValue('userFormEmailInput', 'example@example.com'); + await testSubjects.click('rolesDropdown'); + await testSubjects.setValue('rolesDropdown', 'roleOption-apm_user'); + await testSubjects.click('userFormSaveButton'); + await testSubjects.click('checkboxSelectRow-deleteA11y'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for delete user panel ', async () => { + await testSubjects.click('deleteUserButton'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for edit user panel', async () => { + await testSubjects.click('confirmModalCancelButton'); + await PageObjects.settings.clickLinkText('deleteA11y'); + await a11y.testAppSnapshot(); + }); + + // https://github.com/elastic/eui/issues/2841 + it.skip('a11y test for Change password screen', async () => { + await PageObjects.settings.clickLinkText('deleteA11y'); + await testSubjects.click('changePassword'); + await a11y.testAppSnapshot(); + }); + }); +} diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index 915872d8b3fb0..5ea5c03696479 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -23,6 +23,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/spaces'), require.resolve('./apps/advanced_settings'), require.resolve('./apps/dashboard_edit_panel'), + require.resolve('./apps/users'), ], pageObjects, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts index 1a56a9dfcb4db..39f64dd037945 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts @@ -351,25 +351,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', - }); - }); - }); - - it('should handle failing with a simulated success without savedObjectId', async () => { - await supertest - .post(`/api/actions/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { subAction: 'pushToService', subActionParams: {} }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - actionId: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]', }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts index d1d19da423e65..5d54ea99889c1 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts @@ -352,25 +352,7 @@ export default function resilientTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]', - }); - }); - }); - - it('should handle failing with a simulated success without savedObjectId', async () => { - await supertest - .post(`/api/actions/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { subAction: 'pushToService', subActionParams: {} }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - actionId: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]', }); }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 3f8341df3d295..60b908e2ae228 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -343,25 +343,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', - }); - }); - }); - - it('should handle failing with a simulated success without savedObjectId', async () => { - await supertest - .post(`/api/actions/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { subAction: 'pushToService', subActionParams: {} }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - actionId: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]', }); }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts index 1c2e51637fb41..16a37bdf77662 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts @@ -19,8 +19,7 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; export default function executionStatusAlertTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - // FLAKY: https://github.com/elastic/kibana/issues/79249 - describe.skip('executionStatus', () => { + describe('executionStatus', () => { const objectRemover = new ObjectRemover(supertest); after(async () => await objectRemover.removeAll()); @@ -65,7 +64,6 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon expect(response.status).to.eql(200); const alertId = response.body.id; dates.push(response.body.executionStatus.lastExecutionDate); - dates.push(Date.now()); objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts'); const executionStatus = await waitForStatus(alertId, new Set(['ok'])); @@ -100,7 +98,6 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon expect(response.status).to.eql(200); const alertId = response.body.id; dates.push(response.body.executionStatus.lastExecutionDate); - dates.push(Date.now()); objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts'); const executionStatus = await waitForStatus(alertId, new Set(['active'])); @@ -132,7 +129,6 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon expect(response.status).to.eql(200); const alertId = response.body.id; dates.push(response.body.executionStatus.lastExecutionDate); - dates.push(Date.now()); objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts'); const executionStatus = await waitForStatus(alertId, new Set(['error'])); diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index a9ecaac09db9a..b634e7117e607 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -41,7 +41,7 @@ export default function ({ getService }) { type: 'index-pattern', }, ]); - expect(resp.body.migrationVersion).to.eql({ map: '7.9.0' }); + expect(resp.body.migrationVersion).to.eql({ map: '7.10.0' }); expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); }); }); diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json index f2927af172062..a97ee98123885 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json @@ -54,4 +54,4 @@ }, "docId": "h5toHm0B0I9WX_CznN_V", "timestamp": "2019-09-11T03:40:34.371Z" -} +} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json index 85ce545ed92b0..22f1fc168ae66 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json @@ -156,5 +156,6 @@ "upCount": 93, "y": 1 } - ] + ], + "minInterval": 22801 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json index fe5dc9dd3da3f..f03827c909347 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json @@ -156,5 +156,6 @@ "upCount": 93, "y": 1 } - ] + ], + "minInterval": 22801 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json index e54738cf5dbd7..fbff31ebe03b6 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json @@ -156,5 +156,6 @@ "upCount": 1, "y": 1 } - ] + ], + "minInterval": 22801 } \ No newline at end of file diff --git a/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap b/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap deleted file mode 100644 index 38b009fc73d34..0000000000000 --- a/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap +++ /dev/null @@ -1,280 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CSM page views when there is data returns page views 1`] = ` -Object { - "items": Array [ - Object { - "x": 1600149947000, - "y": 1, - }, - Object { - "x": 1600149957000, - "y": 0, - }, - Object { - "x": 1600149967000, - "y": 0, - }, - Object { - "x": 1600149977000, - "y": 0, - }, - Object { - "x": 1600149987000, - "y": 0, - }, - Object { - "x": 1600149997000, - "y": 0, - }, - Object { - "x": 1600150007000, - "y": 0, - }, - Object { - "x": 1600150017000, - "y": 0, - }, - Object { - "x": 1600150027000, - "y": 1, - }, - Object { - "x": 1600150037000, - "y": 0, - }, - Object { - "x": 1600150047000, - "y": 0, - }, - Object { - "x": 1600150057000, - "y": 0, - }, - Object { - "x": 1600150067000, - "y": 0, - }, - Object { - "x": 1600150077000, - "y": 1, - }, - Object { - "x": 1600150087000, - "y": 0, - }, - Object { - "x": 1600150097000, - "y": 0, - }, - Object { - "x": 1600150107000, - "y": 0, - }, - Object { - "x": 1600150117000, - "y": 0, - }, - Object { - "x": 1600150127000, - "y": 0, - }, - Object { - "x": 1600150137000, - "y": 0, - }, - Object { - "x": 1600150147000, - "y": 0, - }, - Object { - "x": 1600150157000, - "y": 0, - }, - Object { - "x": 1600150167000, - "y": 0, - }, - Object { - "x": 1600150177000, - "y": 1, - }, - Object { - "x": 1600150187000, - "y": 0, - }, - Object { - "x": 1600150197000, - "y": 0, - }, - Object { - "x": 1600150207000, - "y": 1, - }, - Object { - "x": 1600150217000, - "y": 0, - }, - Object { - "x": 1600150227000, - "y": 0, - }, - Object { - "x": 1600150237000, - "y": 1, - }, - ], - "topItems": Array [], -} -`; - -exports[`CSM page views when there is data returns page views with breakdown 1`] = ` -Object { - "items": Array [ - Object { - "Chrome": 1, - "x": 1600149947000, - "y": 1, - }, - Object { - "x": 1600149957000, - "y": 0, - }, - Object { - "x": 1600149967000, - "y": 0, - }, - Object { - "x": 1600149977000, - "y": 0, - }, - Object { - "x": 1600149987000, - "y": 0, - }, - Object { - "x": 1600149997000, - "y": 0, - }, - Object { - "x": 1600150007000, - "y": 0, - }, - Object { - "x": 1600150017000, - "y": 0, - }, - Object { - "Chrome": 1, - "x": 1600150027000, - "y": 1, - }, - Object { - "x": 1600150037000, - "y": 0, - }, - Object { - "x": 1600150047000, - "y": 0, - }, - Object { - "x": 1600150057000, - "y": 0, - }, - Object { - "x": 1600150067000, - "y": 0, - }, - Object { - "Chrome": 1, - "x": 1600150077000, - "y": 1, - }, - Object { - "x": 1600150087000, - "y": 0, - }, - Object { - "x": 1600150097000, - "y": 0, - }, - Object { - "x": 1600150107000, - "y": 0, - }, - Object { - "x": 1600150117000, - "y": 0, - }, - Object { - "x": 1600150127000, - "y": 0, - }, - Object { - "x": 1600150137000, - "y": 0, - }, - Object { - "x": 1600150147000, - "y": 0, - }, - Object { - "x": 1600150157000, - "y": 0, - }, - Object { - "x": 1600150167000, - "y": 0, - }, - Object { - "Chrome": 1, - "x": 1600150177000, - "y": 1, - }, - Object { - "x": 1600150187000, - "y": 0, - }, - Object { - "x": 1600150197000, - "y": 0, - }, - Object { - "Chrome Mobile": 1, - "x": 1600150207000, - "y": 1, - }, - Object { - "x": 1600150217000, - "y": 0, - }, - Object { - "x": 1600150227000, - "y": 0, - }, - Object { - "Chrome Mobile": 1, - "x": 1600150237000, - "y": 1, - }, - ], - "topItems": Array [ - "Chrome", - "Chrome Mobile", - ], -} -`; - -exports[`CSM page views when there is no data returns empty list 1`] = ` -Object { - "items": Array [], - "topItems": Array [], -} -`; - -exports[`CSM page views when there is no data returns empty list with breakdowns 1`] = ` -Object { - "items": Array [], - "topItems": Array [], -} -`; diff --git a/x-pack/test/apm_api_integration/trial/tests/csm/has_rum_data.ts b/x-pack/test/apm_api_integration/trial/tests/csm/has_rum_data.ts new file mode 100644 index 0000000000000..12fdb5ba9704e --- /dev/null +++ b/x-pack/test/apm_api_integration/trial/tests/csm/has_rum_data.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { expectSnapshot } from '../../../common/match_snapshot'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function rumHasDataApiTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('CSM has rum data api', () => { + describe('when there is no data', () => { + it('returns empty list', async () => { + const response = await supertest.get( + '/api/apm/observability_overview/has_rum_data?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=' + ); + + expect(response.status).to.be(200); + expectSnapshot(response.body).toMatchInline(` + Object { + "hasData": false, + } + `); + }); + }); + + describe('when there is data', () => { + before(async () => { + await esArchiver.load('8.0.0'); + await esArchiver.load('rum_8.0.0'); + }); + after(async () => { + await esArchiver.unload('8.0.0'); + await esArchiver.unload('rum_8.0.0'); + }); + + it('returns that it has data and service name with most traffice', async () => { + const response = await supertest.get( + '/api/apm/observability_overview/has_rum_data?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=' + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body).toMatchInline(` + Object { + "hasData": true, + "serviceName": "client", + } + `); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/trial/tests/index.ts b/x-pack/test/apm_api_integration/trial/tests/index.ts index e609279366390..a67dd1bcbd7a8 100644 --- a/x-pack/test/apm_api_integration/trial/tests/index.ts +++ b/x-pack/test/apm_api_integration/trial/tests/index.ts @@ -39,6 +39,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr loadTestFile(require.resolve('./csm/url_search.ts')); loadTestFile(require.resolve('./csm/page_views.ts')); loadTestFile(require.resolve('./csm/js_errors.ts')); + loadTestFile(require.resolve('./csm/has_rum_data.ts')); }); }); } diff --git a/x-pack/test/functional/es_archives/endpoint/pipeline/dns/data.json.gz b/x-pack/test/functional/es_archives/endpoint/pipeline/dns/data.json.gz new file mode 100644 index 0000000000000..5caab4767dbec Binary files /dev/null and b/x-pack/test/functional/es_archives/endpoint/pipeline/dns/data.json.gz differ diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts index 8a72badebd923..a4d8fa8e692bd 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts @@ -15,7 +15,7 @@ export function MachineLearningDataFrameAnalyticsResultsProvider({ return { async assertRegressionEvaluatePanelElementsExists() { - await testSubjects.existOrFail('mlDFAnalyticsRegressionExplorationEvaluatePanel'); + await testSubjects.existOrFail('mlDFExpandableSection-RegressionEvaluation'); await testSubjects.existOrFail('mlDFAnalyticsRegressionGenMSEstat'); await testSubjects.existOrFail('mlDFAnalyticsRegressionGenRSquaredStat'); await testSubjects.existOrFail('mlDFAnalyticsRegressionTrainingMSEstat'); @@ -27,7 +27,7 @@ export function MachineLearningDataFrameAnalyticsResultsProvider({ }, async assertClassificationEvaluatePanelElementsExists() { - await testSubjects.existOrFail('mlDFAnalyticsClassificationExplorationEvaluatePanel'); + await testSubjects.existOrFail('mlDFExpandableSection-ClassificationEvaluation'); await testSubjects.existOrFail('mlDFAnalyticsClassificationExplorationConfusionMatrix'); }, diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/install_by_upload.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/install_by_upload.ts index e6d2affaec0cd..7fa0e0f38179a 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/install_by_upload.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/install_by_upload.ts @@ -9,18 +9,43 @@ import path from 'path'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; -import { warnAndSkipTest } from '../../helpers'; +import { skipIfNoDockerRegistry } from '../../helpers'; -export default function ({ getService }: FtrProviderContext) { +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; const supertest = getService('supertest'); const dockerServers = getService('dockerServers'); - const log = getService('log'); const testPkgArchiveTgz = path.join( path.dirname(__filename), '../fixtures/direct_upload_packages/apache_0.1.4.tar.gz' ); - const testPkgKey = 'apache-0.14'; + const testPkgArchiveZip = path.join( + path.dirname(__filename), + '../fixtures/direct_upload_packages/apache_0.1.4.zip' + ); + const testPkgArchiveInvalidTwoToplevels = path.join( + path.dirname(__filename), + '../fixtures/direct_upload_packages/apache_invalid_two_toplevels_0.1.4.zip' + ); + const testPkgArchiveInvalidNoManifest = path.join( + path.dirname(__filename), + '../fixtures/direct_upload_packages/apache_invalid_no_manifest_0.1.4.zip' + ); + const testPkgArchiveInvalidManifestInvalidYaml = path.join( + path.dirname(__filename), + '../fixtures/direct_upload_packages/apache_invalid_manifest_invalid_yaml_0.1.4.zip' + ); + const testPkgArchiveInvalidManifestMissingField = path.join( + path.dirname(__filename), + '../fixtures/direct_upload_packages/apache_invalid_manifest_missing_field_0.1.4.zip' + ); + const testPkgArchiveInvalidToplevelMismatch = path.join( + path.dirname(__filename), + '../fixtures/direct_upload_packages/apache_invalid_toplevel_mismatch_0.1.4.zip' + ); + + const testPkgKey = 'apache-0.1.4'; const server = dockerServers.get('registry'); const deletePackage = async (pkgkey: string) => { @@ -28,28 +53,125 @@ export default function ({ getService }: FtrProviderContext) { }; describe('installs packages from direct upload', async () => { - after(async () => { - if (server.enabled) { + skipIfNoDockerRegistry(providerContext); + afterEach(async () => { + if (server) { // remove the package just in case it being installed will affect other tests await deletePackage(testPkgKey); } }); it('should install a tar archive correctly', async function () { - if (server.enabled) { - const buf = fs.readFileSync(testPkgArchiveTgz); - const res = await supertest - .post(`/api/ingest_manager/epm/packages`) - .set('kbn-xsrf', 'xxxx') - .type('application/gzip') - .send(buf) - .expect(200); - expect(res.body.response).to.equal( - 'package upload was received ok, but not installed (not implemented yet)' - ); - } else { - warnAndSkipTest(this, log); - } + const buf = fs.readFileSync(testPkgArchiveTgz); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/gzip') + .send(buf) + .expect(200); + expect(res.body.response.length).to.be(23); + }); + + it('should install a zip archive correctly', async function () { + const buf = fs.readFileSync(testPkgArchiveZip); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(200); + expect(res.body.response.length).to.be(18); + }); + + it('should throw an error if the archive is zip but content type is gzip', async function () { + const buf = fs.readFileSync(testPkgArchiveZip); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/gzip') + .send(buf) + .expect(400); + expect(res.error.text).to.equal( + '{"statusCode":400,"error":"Bad Request","message":"Uploaded archive seems empty. Assumed content type was application/gzip, check if this matches the archive type."}' + ); + }); + + it('should throw an error if the archive is tar.gz but content type is zip', async function () { + const buf = fs.readFileSync(testPkgArchiveTgz); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(400); + expect(res.error.text).to.equal( + '{"statusCode":400,"error":"Bad Request","message":"Error during extraction of uploaded package: Error: end of central directory record signature not found. Assumed content type was application/zip, check if this matches the archive type."}' + ); + }); + + it('should throw an error if the archive contains two top-level directories', async function () { + const buf = fs.readFileSync(testPkgArchiveInvalidTwoToplevels); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(400); + expect(res.error.text).to.equal( + '{"statusCode":400,"error":"Bad Request","message":"Package contains more than one top-level directory."}' + ); + }); + + it('should throw an error if the archive does not contain a manifest', async function () { + const buf = fs.readFileSync(testPkgArchiveInvalidNoManifest); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(400); + expect(res.error.text).to.equal( + '{"statusCode":400,"error":"Bad Request","message":"Package must contain a top-level manifest.yml file."}' + ); + }); + + it('should throw an error if the archive manifest contains invalid YAML', async function () { + const buf = fs.readFileSync(testPkgArchiveInvalidManifestInvalidYaml); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(400); + expect(res.error.text).to.equal( + '{"statusCode":400,"error":"Bad Request","message":"Could not parse top-level package manifest: YAMLException: bad indentation of a mapping entry at line 2, column 7:\\n name: apache\\n ^."}' + ); + }); + + it('should throw an error if the archive manifest misses a mandatory field', async function () { + const buf = fs.readFileSync(testPkgArchiveInvalidManifestMissingField); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(400); + expect(res.error.text).to.equal( + '{"statusCode":400,"error":"Bad Request","message":"Invalid top-level package manifest: one or more fields missing of name, version, description, type, categories, format_version"}' + ); + }); + + it('should throw an error if the toplevel directory name does not match the package key', async function () { + const buf = fs.readFileSync(testPkgArchiveInvalidToplevelMismatch); + const res = await supertest + .post(`/api/ingest_manager/epm/packages`) + .set('kbn-xsrf', 'xxxx') + .type('application/zip') + .send(buf) + .expect(400); + expect(res.error.text).to.equal( + '{"statusCode":400,"error":"Bad Request","message":"Name thisIsATypo and version 0.1.4 do not match top-level directory apache-0.1.4"}' + ); }); }); } diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts index 5170867d7b545..0b27498103f2d 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts @@ -192,6 +192,7 @@ export default function (providerContext: FtrProviderContext) { install_version: '0.1.0', install_status: 'installed', install_started_at: res.attributes.install_started_at, + install_source: 'registry', }); }); }); diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/update_assets.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/update_assets.ts index 9af27f5f98558..8608756c37f54 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/update_assets.ts @@ -325,6 +325,7 @@ export default function (providerContext: FtrProviderContext) { install_version: '0.2.0', install_status: 'installed', install_started_at: res.attributes.install_started_at, + install_source: 'registry', }); }); }); diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip new file mode 100644 index 0000000000000..410b00ecde2be Binary files /dev/null and b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_0.1.4.zip differ diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_manifest_invalid_yaml_0.1.4.zip b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_manifest_invalid_yaml_0.1.4.zip new file mode 100644 index 0000000000000..e18db3c0e3df0 Binary files /dev/null and b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_manifest_invalid_yaml_0.1.4.zip differ diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_manifest_missing_field_0.1.4.zip b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_manifest_missing_field_0.1.4.zip new file mode 100644 index 0000000000000..8526f6a53458b Binary files /dev/null and b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_manifest_missing_field_0.1.4.zip differ diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_no_manifest_0.1.4.zip b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_no_manifest_0.1.4.zip new file mode 100644 index 0000000000000..ec410421130c5 Binary files /dev/null and b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_no_manifest_0.1.4.zip differ diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_toplevel_mismatch_0.1.4.zip b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_toplevel_mismatch_0.1.4.zip new file mode 100644 index 0000000000000..18e035e5192c4 Binary files /dev/null and b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_toplevel_mismatch_0.1.4.zip differ diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_two_toplevels_0.1.4.zip b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_two_toplevels_0.1.4.zip new file mode 100644 index 0000000000000..cfe8a809ae92b Binary files /dev/null and b/x-pack/test/ingest_manager_api_integration/apis/fixtures/direct_upload_packages/apache_invalid_two_toplevels_0.1.4.zip differ diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts index 360b91203dfc8..b119e6d58dc35 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts @@ -218,7 +218,7 @@ export default function (providerContext: FtrProviderContext) { .send({ action: { type: 'UPGRADE', - ack_data: { version: '8.0.0', source_uri: 'http://localhost:8000' }, + ack_data: { version: '8.0.0' }, }, }) .expect(200); diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts index d925d77b5b93f..a59b3ff0890f7 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts @@ -19,8 +19,7 @@ export default function (providerContext: FtrProviderContext) { const supertestWithoutAuth = getSupertestWithoutAuth(providerContext); const esClient = getService('es'); - // Failing: See https://github.com/elastic/kibana/issues/75241 - describe.skip('fleet_agent_flow', () => { + describe('fleet_agent_flow', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('empty_kibana'); diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts index a783f806c03ee..04e32b2b80f56 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts @@ -5,9 +5,11 @@ */ import expect from '@kbn/expect/expect.js'; +import semver from 'semver'; import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; import { setupIngest } from './services'; import { skipIfNoDockerRegistry } from '../../../helpers'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../../../plugins/ingest_manager/common'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -18,16 +20,15 @@ export default function (providerContext: FtrProviderContext) { describe('fleet upgrade agent', () => { skipIfNoDockerRegistry(providerContext); setupIngest(providerContext); - before(async () => { + beforeEach(async () => { await esArchiver.loadIfNeeded('fleet/agents'); }); - after(async () => { + afterEach(async () => { await esArchiver.unload('fleet/agents'); }); it('should respond 200 to upgrade agent and update the agent SO', async () => { - const kibanaVersionAccessor = kibanaServer.version; - const kibanaVersion = await kibanaVersionAccessor.get(); + const kibanaVersion = await kibanaServer.version.get(); await supertest .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) .set('kbn-xsrf', 'xxx') @@ -36,22 +37,150 @@ export default function (providerContext: FtrProviderContext) { source_uri: 'http://path/to/download', }) .expect(200); - const res = await kibanaServer.savedObjects.get({ - type: 'fleet-agents', - id: 'agent1', - }); - expect(res.attributes.upgrade_started_at).to.be.ok(); + + const res = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1`) + .set('kbn-xsrf', 'xxx'); + expect(typeof res.body.item.upgrade_started_at).to.be('string'); + }); + it('should respond 200 to upgrade agent and update the agent SO without source_uri', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + }) + .expect(200); + const res = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1`) + .set('kbn-xsrf', 'xxx'); + expect(typeof res.body.item.upgrade_started_at).to.be('string'); }); it('should respond 400 if trying to upgrade to a version that does not match installed kibana version', async () => { + const kibanaVersion = await kibanaServer.version.get(); + const higherVersion = semver.inc(kibanaVersion, 'patch'); await supertest .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) .set('kbn-xsrf', 'xxx') .send({ - version: '8.0.1', + version: higherVersion, source_uri: 'http://path/to/download', }) .expect(400); }); + it('should respond 400 if trying to upgrade an agent that is unenrolling', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + force: true, + }); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + }) + .expect(400); + }); + it('should respond 400 if trying to upgrade an agent that is unenrolled', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { unenrolled_at: new Date().toISOString() }, + }); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + }) + .expect(400); + }); + + it('should respond 200 to bulk upgrade agents and update the agent SOs', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + agents: ['agent1', 'agent2'], + }) + .expect(200); + + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/ingest_manager/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/ingest_manager/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + ]); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('string'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); + }); + + it('should allow to upgrade multiple agents by kuery', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: 'fleet-agents.active: true', + version: kibanaVersion, + }) + .expect(200); + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/ingest_manager/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/ingest_manager/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + ]); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('string'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); + }); + + it('should not upgrade an unenrolling agent during bulk_upgrade', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + force: true, + }); + await supertest + .post(`/api/ingest_manager/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent1', 'agent2'], + version: kibanaVersion, + }); + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/ingest_manager/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/ingest_manager/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + ]); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); + }); + it('should not upgrade an unenrolled agent during bulk_upgrade', async () => { + const kibanaVersion = await kibanaServer.version.get(); + kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { unenrolled_at: new Date().toISOString() }, + }); + await supertest + .post(`/api/ingest_manager/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent1', 'agent2'], + version: kibanaVersion, + }); + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/ingest_manager/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/ingest_manager/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + ]); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); + }); }); } diff --git a/x-pack/test/ingest_manager_api_integration/config.ts b/x-pack/test/ingest_manager_api_integration/config.ts index d11884667c48a..193ac0d5974e6 100644 --- a/x-pack/test/ingest_manager_api_integration/config.ts +++ b/x-pack/test/ingest_manager_api_integration/config.ts @@ -12,7 +12,7 @@ import { defineDockerServersConfig } from '@kbn/test'; // Docker image to use for Ingest Manager API integration tests. // This hash comes from the commit hash here: https://github.com/elastic/package-storage/commit export const dockerImage = - 'docker.elastic.co/package-registry/distribution:a5132271ad37209d6978018bfe6e224546d719a8'; + 'docker.elastic.co/package-registry/distribution:fb58d670bafbac7e9e28baf6d6f99ba65cead548'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); diff --git a/x-pack/test/login_selector_api_integration/services.ts b/x-pack/test/login_selector_api_integration/services.ts deleted file mode 100644 index 8bb2dae90bf59..0000000000000 --- a/x-pack/test/login_selector_api_integration/services.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { services as commonServices } from '../common/services'; -import { services as apiIntegrationServices } from '../api_integration/services'; - -export const services = { - ...commonServices, - randomness: apiIntegrationServices.randomness, - supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, -}; diff --git a/x-pack/test/saml_api_integration/apis/index.ts b/x-pack/test/saml_api_integration/apis/index.ts deleted file mode 100644 index 174e7828a11d4..0000000000000 --- a/x-pack/test/saml_api_integration/apis/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FtrProviderContext } from '../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('apis SAML', function () { - this.tags('ciGroup6'); - loadTestFile(require.resolve('./security')); - }); -} diff --git a/x-pack/test/saml_api_integration/services.ts b/x-pack/test/saml_api_integration/services.ts deleted file mode 100644 index de300af03bbe6..0000000000000 --- a/x-pack/test/saml_api_integration/services.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { services as commonServices } from '../common/services'; -import { services as apiIntegrationServices } from '../api_integration/services'; - -export const services = { - ...commonServices, - randomness: apiIntegrationServices.randomness, - legacyEs: apiIntegrationServices.legacyEs, - supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, -}; diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml b/x-pack/test/security_api_integration/fixtures/saml/idp_metadata.xml similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/idp_metadata.xml rename to x-pack/test/security_api_integration/fixtures/saml/idp_metadata.xml diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml b/x-pack/test/security_api_integration/fixtures/saml/idp_metadata_2.xml similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml rename to x-pack/test/security_api_integration/fixtures/saml/idp_metadata_2.xml diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/kibana.json b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/saml_provider/kibana.json rename to x-pack/test/security_api_integration/fixtures/saml/saml_provider/kibana.json diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/metadata.xml b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/metadata.xml similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/saml_provider/metadata.xml rename to x-pack/test/security_api_integration/fixtures/saml/saml_provider/metadata.xml diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/index.ts b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/index.ts similarity index 85% rename from x-pack/test/saml_api_integration/fixtures/saml_provider/server/index.ts rename to x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/index.ts index d4dda70cef694..25aa4ad61900e 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/index.ts +++ b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializer } from '../../../../../../src/core/server'; +import { PluginInitializer } from '../../../../../../../src/core/server'; import { initRoutes } from './init_routes'; export const plugin: PluginInitializer = () => ({ diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/init_routes.ts similarity index 96% rename from x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts rename to x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/init_routes.ts index f2c91ea7d1e03..10ec104db939b 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts +++ b/x-pack/test/security_api_integration/fixtures/saml/saml_provider/server/init_routes.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from '../../../../../../src/core/server'; +import { CoreSetup } from '../../../../../../../src/core/server'; import { getSAMLResponse, getSAMLRequestId } from '../../saml_tools'; export function initRoutes(core: CoreSetup) { diff --git a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts b/x-pack/test/security_api_integration/fixtures/saml/saml_tools.ts similarity index 100% rename from x-pack/test/saml_api_integration/fixtures/saml_tools.ts rename to x-pack/test/security_api_integration/fixtures/saml/saml_tools.ts diff --git a/x-pack/test/login_selector_api_integration/config.ts b/x-pack/test/security_api_integration/login_selector.config.ts similarity index 95% rename from x-pack/test/login_selector_api_integration/config.ts rename to x-pack/test/security_api_integration/login_selector.config.ts index fb711a8bef488..0e43715ba808e 100644 --- a/x-pack/test/login_selector_api_integration/config.ts +++ b/x-pack/test/security_api_integration/login_selector.config.ts @@ -23,14 +23,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const pkiKibanaCAPath = resolve(__dirname, '../pki_api_integration/fixtures/kibana_ca.crt'); - const saml1IdPMetadataPath = resolve( - __dirname, - '../saml_api_integration/fixtures/idp_metadata.xml' - ); - const saml2IdPMetadataPath = resolve( - __dirname, - '../saml_api_integration/fixtures/idp_metadata_2.xml' - ); + const saml1IdPMetadataPath = resolve(__dirname, './fixtures/saml/idp_metadata.xml'); + const saml2IdPMetadataPath = resolve(__dirname, './fixtures/saml/idp_metadata_2.xml'); const servers = { ...xPackAPITestsConfig.get('servers'), @@ -45,7 +39,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }; return { - testFiles: [require.resolve('./apis')], + testFiles: [require.resolve('./tests/login_selector')], servers, security: { disableTestUser: true }, services: { @@ -54,7 +48,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), }, junit: { - reportName: 'X-Pack Login Selector API Integration Tests', + reportName: 'X-Pack Security API Integration Tests (Login Selector)', }, esTestCluster: { diff --git a/x-pack/test/saml_api_integration/config.ts b/x-pack/test/security_api_integration/saml.config.ts similarity index 92% rename from x-pack/test/saml_api_integration/config.ts rename to x-pack/test/security_api_integration/saml.config.ts index 9edadca4c1667..133e52d68d87e 100644 --- a/x-pack/test/saml_api_integration/config.ts +++ b/x-pack/test/security_api_integration/saml.config.ts @@ -14,10 +14,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port'); - const idpPath = resolve(__dirname, '../../test/saml_api_integration/fixtures/idp_metadata.xml'); + const idpPath = resolve(__dirname, './fixtures/saml/idp_metadata.xml'); return { - testFiles: [require.resolve('./apis')], + testFiles: [require.resolve('./tests/saml')], servers: xPackAPITestsConfig.get('servers'), security: { disableTestUser: true }, services: { @@ -26,7 +26,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), }, junit: { - reportName: 'X-Pack SAML API Integration Tests', + reportName: 'X-Pack Security API Integration Tests (SAML)', }, esTestCluster: { diff --git a/x-pack/test/security_api_integration/services.ts b/x-pack/test/security_api_integration/services.ts index e2abfa71451bc..a8d8048462693 100644 --- a/x-pack/test/security_api_integration/services.ts +++ b/x-pack/test/security_api_integration/services.ts @@ -9,6 +9,5 @@ import { services as apiIntegrationServices } from '../api_integration/services' export const services = { ...commonServices, - legacyEs: apiIntegrationServices.legacyEs, supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, }; diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts similarity index 96% rename from x-pack/test/login_selector_api_integration/apis/login_selector.ts rename to x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts index 44582355cf890..2881020f521ee 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts @@ -10,13 +10,13 @@ import { resolve } from 'path'; import url from 'url'; import { CA_CERT_PATH } from '@kbn/dev-utils'; import expect from '@kbn/expect'; -import { getStateAndNonce } from '../../oidc_api_integration/fixtures/oidc_tools'; +import { getStateAndNonce } from '../../../oidc_api_integration/fixtures/oidc_tools'; import { getMutualAuthenticationResponseToken, getSPNEGOToken, -} from '../../kerberos_api_integration/fixtures/kerberos_tools'; -import { getSAMLRequestId, getSAMLResponse } from '../../saml_api_integration/fixtures/saml_tools'; -import { FtrProviderContext } from '../ftr_provider_context'; +} from '../../../kerberos_api_integration/fixtures/kerberos_tools'; +import { getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml/saml_tools'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const randomness = getService('randomness'); @@ -29,7 +29,7 @@ export default function ({ getService }: FtrProviderContext) { const CA_CERT = readFileSync(CA_CERT_PATH); const CLIENT_CERT = readFileSync( - resolve(__dirname, '../../pki_api_integration/fixtures/first_client.p12') + resolve(__dirname, '../../../pki_api_integration/fixtures/first_client.p12') ); async function checkSessionCookie( @@ -97,11 +97,23 @@ export default function ({ getService }: FtrProviderContext) { // to fully authenticate user yet. const intermediateAuthCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + // When login page is accessed directly. await supertest .get('/login') .ca(CA_CERT) .set('Cookie', intermediateAuthCookie.cookieString()) .expect(200); + + // When user tries to access any other page in Kibana. + const response = await supertest + .get('/abc/xyz/handshake?one=two three') + .ca(CA_CERT) + .set('Cookie', intermediateAuthCookie.cookieString()) + .expect(302); + expect(response.headers['set-cookie']).to.be(undefined); + expect(response.headers.location).to.be( + '/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three' + ); }); describe('SAML', () => { diff --git a/x-pack/test/login_selector_api_integration/apis/index.ts b/x-pack/test/security_api_integration/tests/login_selector/index.ts similarity index 65% rename from x-pack/test/login_selector_api_integration/apis/index.ts rename to x-pack/test/security_api_integration/tests/login_selector/index.ts index a4d92ebc2e109..408bfe0b52c4b 100644 --- a/x-pack/test/login_selector_api_integration/apis/index.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/index.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('apis', function () { + describe('security APIs - Login Selector', function () { this.tags('ciGroup6'); - loadTestFile(require.resolve('./login_selector')); + loadTestFile(require.resolve('./basic_functionality')); }); } diff --git a/x-pack/test/saml_api_integration/apis/security/index.ts b/x-pack/test/security_api_integration/tests/saml/index.ts similarity index 84% rename from x-pack/test/saml_api_integration/apis/security/index.ts rename to x-pack/test/security_api_integration/tests/saml/index.ts index aac9a82ec5680..882c8774e54e6 100644 --- a/x-pack/test/saml_api_integration/apis/security/index.ts +++ b/x-pack/test/security_api_integration/tests/saml/index.ts @@ -7,7 +7,9 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('security', () => { + describe('security APIs - SAML', function () { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./saml_login')); }); } diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/security_api_integration/tests/saml/saml_login.ts similarity index 99% rename from x-pack/test/saml_api_integration/apis/security/saml_login.ts rename to x-pack/test/security_api_integration/tests/saml/saml_login.ts index 2da7c92cd07b6..8770d87c0cf8c 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/security_api_integration/tests/saml/saml_login.ts @@ -9,7 +9,11 @@ import url from 'url'; import { delay } from 'bluebird'; import expect from '@kbn/expect'; import request, { Cookie } from 'request'; -import { getLogoutRequest, getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml_tools'; +import { + getLogoutRequest, + getSAMLRequestId, + getSAMLResponse, +} from '../../fixtures/saml/saml_tools'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index 01e2ad76fb3d2..703180442f8f5 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -30,7 +30,9 @@ export default function ({ getService }: FtrProviderContext) { } async function getNumberOfSessionDocuments() { - return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; + return (((await es.search({ index: '.kibana_security_session*' })).hits.total as unknown) as { + value: number; + }).value; } describe('Session Idle cleanup', () => { diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index 6036acf3d1cf1..8b136e540f13f 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -27,7 +27,9 @@ export default function ({ getService }: FtrProviderContext) { } async function getNumberOfSessionDocuments() { - return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; + return (((await es.search({ index: '.kibana_security_session*' })).hits.total as unknown) as { + value: number; + }).value; } describe('Session Lifespan cleanup', () => { diff --git a/x-pack/test/security_functional/login_selector.config.ts b/x-pack/test/security_functional/login_selector.config.ts index bdb4778740503..9fc4c54ba1344 100644 --- a/x-pack/test/security_functional/login_selector.config.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -20,8 +20,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ); const kibanaPort = kibanaFunctionalConfig.get('servers.kibana.port'); - const idpPath = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider/metadata.xml'); - const samlIdPPlugin = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider'); + const idpPath = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider/metadata.xml' + ); + const samlIdPPlugin = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider' + ); return { testFiles: [resolve(__dirname, './tests/login_selector')], diff --git a/x-pack/test/security_functional/saml.config.ts b/x-pack/test/security_functional/saml.config.ts index 9d925bee480a8..1e032bdcc6ac7 100644 --- a/x-pack/test/security_functional/saml.config.ts +++ b/x-pack/test/security_functional/saml.config.ts @@ -20,8 +20,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ); const kibanaPort = kibanaFunctionalConfig.get('servers.kibana.port'); - const idpPath = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider/metadata.xml'); - const samlIdPPlugin = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider'); + const idpPath = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider/metadata.xml' + ); + const samlIdPPlugin = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider' + ); return { testFiles: [resolve(__dirname, './tests/saml')], diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index dd28752bf29b4..5b5949821580f 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -64,8 +64,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ], ]; - // Failing: See https://github.com/elastic/kibana/issues/77278 - describe.skip('endpoint list', function () { + describe('endpoint list', function () { this.tags('ciGroup7'); const sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -219,8 +218,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await deleteAllDocsFromMetadataCurrentIndex(getService); }); it('for the kql query: na, table shows an empty list', async () => { - await testSubjects.setValue('adminSearchBar', 'na'); - await (await testSubjects.find('querySubmitButton')).click(); + const adminSearchBar = await testSubjects.find('adminSearchBar'); + await adminSearchBar.clearValueWithKeyboard(); + await adminSearchBar.type('na'); + const querySubmitButton = await testSubjects.find('querySubmitButton'); + await querySubmitButton.click(); const expectedDataFromQuery = [ [ 'Hostname', @@ -240,18 +242,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); expect(tableData).to.eql(expectedDataFromQuery); }); - it('for the kql query: HostDetails.Endpoint.policy.applied.id : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A", table shows 2 items', async () => { - await testSubjects.setValue('adminSearchBar', ' '); - await (await testSubjects.find('querySubmitButton')).click(); - - const endpointListTableTotal = await testSubjects.getVisibleText('endpointListTableTotal'); - - await testSubjects.setValue( - 'adminSearchBar', + const adminSearchBar = await testSubjects.find('adminSearchBar'); + await adminSearchBar.clearValueWithKeyboard(); + await adminSearchBar.type( 'HostDetails.Endpoint.policy.applied.id : "C2A9093E-E289-4C0A-AA44-8C32A414FA7A" ' ); - await (await testSubjects.find('querySubmitButton')).click(); + const querySubmitButton = await testSubjects.find('querySubmitButton'); + await querySubmitButton.click(); const expectedDataFromQuery = [ [ 'Hostname', @@ -287,11 +285,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '', ], ]; - - await pageObjects.endpoint.waitForVisibleTextToChange( - 'endpointListTableTotal', - endpointListTableTotal - ); + await pageObjects.endpoint.waitForTableToHaveData('endpointListTable'); const tableData = await pageObjects.endpointPageUtils.tableData('endpointListTable'); expect(tableData).to.eql(expectedDataFromQuery); }); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 137f24432976a..b15fab96470e0 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -30,8 +30,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { port, }); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/72102 - describe.skip('When on the Endpoint Policy Details Page', function () { + describe('When on the Endpoint Policy Details Page', function () { this.tags(['ciGroup7']); describe('with an invalid policy id', () => { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/package.ts b/x-pack/test/security_solution_endpoint_api_int/apis/package.ts index 3b5873d1fe0cd..afbf0dcd7bd13 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/package.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/package.ts @@ -5,6 +5,10 @@ */ import expect from '@kbn/expect'; import { SearchResponse } from 'elasticsearch'; +import { + ResolverPaginatedEvents, + SafeEndpointEvent, +} from '../../../plugins/security_solution/common/endpoint/types'; import { eventsIndexPattern } from '../../../plugins/security_solution/common/endpoint/constants'; import { EndpointDocGenerator, @@ -12,6 +16,7 @@ import { } from '../../../plugins/security_solution/common/endpoint/generate_data'; import { FtrProviderContext } from '../ftr_provider_context'; import { InsertedEvents, processEventsIndex } from '../services/resolver'; +import { deleteEventsStream } from './data_stream_helper'; interface EventIngested { event: { @@ -35,6 +40,8 @@ interface NetworkEvent { const networkIndex = 'logs-endpoint.events.network-default'; export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); const resolver = getService('resolverGenerator'); const es = getService('es'); const generator = new EndpointDocGenerator('data'); @@ -59,6 +66,72 @@ export default function ({ getService }: FtrProviderContext) { }; describe('Endpoint package', () => { + describe('network processors', () => { + let networkIndexData: InsertedEvents; + + after(async () => { + await resolver.deleteData(networkIndexData); + }); + + it('handles events without the `network.protocol` field being defined', async () => { + const eventWithoutNetworkObject = generator.generateEvent(); + // ensure that `network.protocol` does not exist in the event to test that the pipeline handles those type of events + delete eventWithoutNetworkObject.network; + + // this call will fail if the pipeline fails + networkIndexData = await resolver.insertEvents([eventWithoutNetworkObject], networkIndex); + const eventWithBothIPs = await searchForID( + networkIndexData.eventsInfo[0]._id + ); + + // ensure that the event was inserted into ES + expect(eventWithBothIPs.body.hits.hits[0]._source.event?.id).to.be( + eventWithoutNetworkObject.event?.id + ); + }); + }); + + describe('dns processor', () => { + before(async () => { + await esArchiver.load('endpoint/pipeline/dns', { useCreate: true }); + }); + + after(async () => { + await deleteEventsStream(getService); + }); + + it('does not set dns.question.type if it is already populated', async () => { + // this id comes from the es archive file endpoint/pipeline/dns + const id = 'LrLSOVHVsFY94TAi++++++eF'; + const { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events?limit=1`) + .set('kbn-xsrf', 'xxx') + .send({ + filter: `event.id:"${id}"`, + }) + .expect(200); + expect(body.events.length).to.eql(1); + expect((body.events[0] as SafeEndpointEvent).dns?.question?.name).to.eql('www.google.com'); + expect((body.events[0] as SafeEndpointEvent).dns?.question?.type).to.eql('INVALID_VALUE'); + }); + + it('sets dns.question.type if it is not populated', async () => { + // this id comes from the es archive file endpoint/pipeline/dns + const id = 'LrLSOVHVsFY94TAi++++++eP'; + const { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events?limit=1`) + .set('kbn-xsrf', 'xxx') + .send({ + filter: `event.id:"${id}"`, + }) + .expect(200); + expect(body.events.length).to.eql(1); + expect((body.events[0] as SafeEndpointEvent).dns?.question?.name).to.eql('www.aol.com'); + // This value is parsed out of the message field in the event. type 28 = AAAA + expect((body.events[0] as SafeEndpointEvent).dns?.question?.type).to.eql('AAAA'); + }); + }); + describe('ingested processor', () => { let event: Event; let genData: InsertedEvents; @@ -92,6 +165,7 @@ export default function ({ getService }: FtrProviderContext) { const eventWithSourceOnly = generator.generateEvent({ extensions: { source: { ip: '8.8.8.8' } }, }); + networkIndexData = await resolver.insertEvents( [eventWithBothIPs, eventWithSourceOnly], networkIndex diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 628f2edefb079..8e378ff1f4a6a 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -20,6 +20,7 @@ { "path": "../../src/core/tsconfig.json" }, { "path": "../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../src/plugins/kibana_react/tsconfig.json" }, - { "path": "../plugins/licensing/tsconfig.json" } + { "path": "../plugins/licensing/tsconfig.json" }, + { "path": "../plugins/global_search/tsconfig.json" }, ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 9a52aca381e87..f751aac1806dd 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -12,7 +12,8 @@ "plugins/security_solution/cypress/**/*", "plugins/apm/e2e/cypress/**/*", "plugins/apm/scripts/**/*", - "plugins/licensing/**/*" + "plugins/licensing/**/*", + "plugins/global_search/**/*", ], "compilerOptions": { "paths": { @@ -28,6 +29,7 @@ { "path": "../src/core/tsconfig.json" }, { "path": "../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../src/plugins/kibana_react/tsconfig.json" }, - { "path": "./plugins/licensing/tsconfig.json" } + { "path": "./plugins/licensing/tsconfig.json" }, + { "path": "./plugins/global_search/tsconfig.json" }, ] } diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index 0b4c46b893aa8..a389bbcf0272b 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -1,6 +1,7 @@ { "include": [], "references": [ - { "path": "./plugins/licensing/tsconfig.json" } + { "path": "./plugins/licensing/tsconfig.json" }, + { "path": "./plugins/global_search/tsconfig.json" }, ] } diff --git a/yarn.lock b/yarn.lock index 6b0449a7e6ec9..eb31eabc5749d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1268,10 +1268,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@29.0.0": - version "29.0.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-29.0.0.tgz#1c8d822c62ad5e29298a3a36f5b02fd9b32a5550" - integrity sha512-YsDjtN/nRA4vvWukg5FDN4iPQgHUVxDwn/JZ1mArCeMe34JwzYJlEkk6Z/+iNbJOZQNHngmV8I2TStcP8k82gg== +"@elastic/eui@29.3.0": + version "29.3.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-29.3.0.tgz#5fd74110d9e3c9634566b37f5696947bce27c083" + integrity sha512-Ga/IsPXQajmYySliuGmux1UgqIQWNZssoCdT6ZGylZSVMdiKk+TJTh06eebGoTLrMXBJcNRV3JauQxeErQaarw== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160"