diff --git a/.gitignore b/.gitignore index 05f5291b2e4d6..919720f04c672 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .aws-config.json .signing-config.json +/api_docs .ackrc /.es /.chromium diff --git a/dev_docs/tutorials/data/search.mdx b/dev_docs/tutorials/data/search.mdx new file mode 100644 index 0000000000000..69b4d5dab58b5 --- /dev/null +++ b/dev_docs/tutorials/data/search.mdx @@ -0,0 +1,496 @@ +--- +id: kibDevTutorialDataSearchAndSessions +slug: /kibana-dev-docs/tutorials/data/search-and-sessions +title: Kibana data.search Services +summary: Kibana Search Services +date: 2021-02-10 +tags: ['kibana', 'onboarding', 'dev', 'tutorials', 'search', 'sessions', 'search-sessions'] +--- + +## Search service + +### Low level search + +Searching data stored in Elasticsearch can be done in various ways, for example using the Elasticsearch REST API or using an `Elasticsearch Client` for low level access. + +However, the recommended and easist way to search Elasticsearch is by using the low level search service. The service is exposed from the `data` plugin, and by using it, you not only gain access to the data you stored, but also to capabilities, such as Custom Search Strategies, Asynchronous Search, Partial Results, Search Sessions, and more. + +Here is a basic example for using the `data.search` service from a custom plugin: + +```ts +import { CoreStart, Plugin } from 'kibana/public'; +import { DataPublicPluginStart, isCompleteResponse, isErrorResponse } from import { DataPublicPluginStart, isCompleteResponse, isErrorResponse } from '../../src/plugins/data'; + +export interface MyPluginStartDependencies { + data: DataPublicPluginStart; +} + +export class MyPlugin implements Plugin { + public start(core: CoreStart, { data }: MyPluginStartDependencies) { + const query = { + filter: [{ + match_all: {} + }], + }; + const req = { + params: { + index: 'my-index-*', + body: { + query, + aggs: {}, + }, + } + }; + data.search.search(req).subscribe({ + next: (result) => { + if (isCompleteResponse(res)) { + // handle search result + } else if (isErrorResponse(res)) { + // handle error, this means that some results were returned, but the search has failed to complete. + } else { + // handle partial results if you want. + } + }, + error: (e) => { + // handle error thrown, for example a server hangup + }, + }) + } +} +``` + +Note: The `data` plugin contains services to help you generate the `query` and `aggs` portions, as well as managing indices using the `data.indexPatterns` service. + + + The `data.search` service is available on both server and client, with similar APIs. + + +#### Error handling + +The `search` method can throw several types of errors, for example: + + - `EsError` for errors originating in Elasticsearch errors + - `PainlessError` for errors originating from a Painless script + - `AbortError` if the search was aborted via an `AbortController` + - `HttpError` in case of a network error + +To display the errors in the context of an application, use the helper method provided on the `data.search` service. These errors are shown in a toast message, using the `core.notifications` service. + +```ts +data.search.search(req).subscribe({ + next: (result) => {}, + error: (e) => { + data.search.showError(e); + }, +}) +``` + +If you decide to handle errors by yourself, watch for errors coming from `Elasticsearch`. They have an additional `attributes` property that holds the raw error from `Elasticsearch`. + +```ts +data.search.search(req).subscribe({ + next: (result) => {}, + error: (e) => { + if (e instanceof IEsError) { + showErrorReason(e.attributes); + } + }, +}) +``` + +#### Stop a running search + +The search service `search` method supports a second argument called `options`. One of these options provides an `abortSignal` to stop searches from running to completion, if the result is no longer needed. + +```ts +import { AbortError } from '../../src/data/public'; + +const abortController = new AbortController(); +data.search.search(req, { + abortSignal: abortController.signal, +}).subscribe({ + next: (result) => { + // handle result + }, + error: (e) => { + if (e instanceof AbortError) { + // you can ignore this error + return; + } + // handle error, for example a server hangup + }, +}); + +// Abort the search request after a second +setTimeout(() => { + abortController.abort(); +}, 1000); +``` + +#### Search strategies + +By default, the search service uses the DSL query and aggregation syntax and returns the response from Elasticsearch as is. It also provides several additional basic strategies, such as Async DSL (`x-pack` default) and EQL. + +For example, to run an EQL query using the `data.search` service, you should to specify the strategy name using the options parameter: + +```ts +const req = getEqlRequest(); +data.search.search(req, { + strategy: EQL_SEARCH_STRATEGY, +}).subscribe({ + next: (result) => { + // handle EQL result + }, +}); +``` + +##### Custom search strategies + +To use a different query syntax, preprocess the request, or process the response before returning it to the client, you can create and register a custom search strategy to encapsulate your custom logic. + +The following example shows how to define, register, and use a search strategy that preprocesses the request before sending it to the default DSL search strategy, and then processes the response before returning. + +```ts +// ./myPlugin/server/myStrategy.ts + +/** + * Your custom search strategy should implement the ISearchStrategy interface, requiring at minimum a `search` function. + */ +export const mySearchStrategyProvider = ( + data: PluginStart +): ISearchStrategy => { + const preprocessRequest = (request: IMyStrategyRequest) => { + // Custom preprocessing + } + + const formatResponse = (response: IMyStrategyResponse) => { + // Custom post-processing + } + + // Get the default search strategy + const es = data.search.getSearchStrategy(ES_SEARCH_STRATEGY); + return { + search: (request, options, deps) => { + return formatResponse(es.search(preprocessRequest(request), options, deps)); + }, + }; +}; +``` + +```ts +// ./myPlugin/server/plugin.ts +import type { + CoreSetup, + CoreStart, + Plugin, +} from 'kibana/server'; + +import { mySearchStrategyProvider } from './my_strategy'; + +/** + * Your plugin will receive the `data` plugin contact in both the setup and start lifecycle hooks. + */ +export interface MyPluginSetupDeps { + data: PluginSetup; +} + +export interface MyPluginStartDeps { + data: PluginStart; +} + +/** + * In your custom server side plugin, register the strategy from the setup contract + */ +export class MyPlugin implements Plugin { + public setup( + core: CoreSetup, + deps: MyPluginSetupDeps + ) { + core.getStartServices().then(([_, depsStart]) => { + const myStrategy = mySearchStrategyProvider(depsStart.data); + deps.data.search.registerSearchStrategy('myCustomStrategy', myStrategy); + }); + } +} +``` + +```ts +// ./myPlugin/public/plugin.ts +const req = getRequest(); +data.search.search(req, { + strategy: 'myCustomStrategy', +}).subscribe({ + next: (result) => { + // handle result + }, +}); +``` + +##### Async search and custom async search strategies + +The open source default search strategy (`ES_SEARCH_STRATEGY`), run searches synchronously, keeping an open connection to Elasticsearch while the query executes. The duration of these queries is restricted by the `elasticsearch.requestTimeout` setting in `kibana.yml`, which is 30 seconds by default. + +This synchronous execution works great in most cases. However, with the introduction of features such as `data tiers` and `runtime fields`, the need to allow slower-running queries, where holding an open connection might be inefficient, has increased. In 7.7, `Elasticsearch` introduced the [async_search API](https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html), allowing a query to run longer without keeping an open connection. Instead, the initial search request returns an ID that identifies the search running in `Elasticsearch`. This ID can then be used to retrieve, cancel, or manage the search result. + +The [async_search API](https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html) is what drives more advanced `Kibana` `search` features, such as `partial results` and `search sessions`. [When available](https://www.elastic.co/subscriptions), the default search strategy of `Kibana` is automatically set to the **async** default search strategy (`ENHANCED_ES_SEARCH_STRATEGY`), empowering Kibana to run longer queries, with an **optional** duration restriction defined by the UI setting `search:timeout`. + +If you are implementing your own async custom search strategy, make sure to implement `cancel` and `extend`, as shown in the following example: + +```ts +// ./myPlugin/server/myEnhancedStrategy.ts +export const myEnhancedSearchStrategyProvider = ( + data: PluginStart +): ISearchStrategy => { + // Get the default search strategy + const ese = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); + return { + search: (request, options, deps) => { + // search will be called multiple times, + // be sure your response formatting is capable of handling partial results, as well as the final result. + return formatResponse(ese.search(request, options, deps)); + }, + cancel: async (id, options, deps) => { + // call the cancel method of the async strategy you are using or implement your own cancellation function. + await ese.cancel(id, options, deps); + }, + extend: async (id, keepAlive, options, deps) => { + // async search results are not stored indefinitely. By default, they expire after 7 days (or as defined by xpack.data_enhanced.search.sessions.defaultExpiration setting in kibana.yml). + // call the extend method of the async strategy you are using or implement your own extend function. + await ese.extend(id, options, deps); + }, + }; +}; +``` + +### High level search + +The high level search service is a simplified way to create and run search requests, without writing custom DSL queries. + +#### Search source + +```ts +function searchWithSearchSource() { + const indexPattern = data.indexPatterns.getDefault(); + const query = data.query.queryString.getQuery(); + const filters = data.query.filterManager.getFilters(); + const timefilter = data.query.timefilter.timefilter.createFilter(indexPattern); + if (timefilter) { + filters.push(timefilter); + } + + const searchSource = await data.search.searchSource.create(); + + searchSource + .setField('index', indexPattern) + .setField('filter', filters) + .setField('query', query) + .setField('fields', selectedFields.length ? selectedFields.map((f) => f.name) : ['*']) + .setField('aggs', getAggsDsl()); + + searchSource.fetch$().subscribe({ + next: () => {}, + error: () => {}, + }); +} +``` + +### Partial results + +When searching using an `async` strategy (such as async DSL and async EQL), the search service will stream back partial results. + +Although you can ignore the partial results and wait for the final result before rendering, you can also use the partial results to create a more interactive experience for your users. It is highly advised, however, to make sure users are aware that the results they are seeing are partial. + +```ts +// Handling partial results +data.search.search(req).subscribe({ + next: (result) => { + if (isCompleteResponse(res)) { + renderFinalResult(res); + } else if (isPartialResponse(res)) { + renderPartialResult(res); + } + }, +}) + +// Skipping partial results +const finalResult = await data.search.search(req).toPromise(); +``` + +### Search sessions + +A search session is a higher level concept than search. A search session describes a grouping of one or more async search requests with additional context. + +Search sessions are handy when you want to enable a user to run something asynchronously (for example, a dashboard over a long period of time), and then quickly restore the results at a later time. The `Search Service` transparently fetches results from the `.async-search` index, instead of running each request again. + +Internally, any search run within a search session is saved into an object, allowing Kibana to manage their lifecycle. Most saved objects are deleted automatically after a short period of time, but if a user chooses to save the search session, the saved object is persisted, so that results can be restored in a later time. + +Stored search sessions are listed in the *Management* application, under *Kibana > Search Sessions*, making it easy to find, manage, and restore them. + +As a developer, you might encounter these two common, use cases: + + * Running a search inside an existing search session + * Supporting search sessions in your application + +#### Running a search inside an existing search session + +For this example, assume you are implementing a new type of `Embeddable` that will be shown on dashboards. The same principle applies, however, to any search requests that you are running, as long as the application you are running inside is managing an active session. + +Because the Dashboard application is already managing a search session, all you need to do is pass down the `searchSessionId` argument to any `search` call. This applies to both the low and high level search APIs. + +The search information will be added to the saved object for the search session. + +```ts +export class SearchEmbeddable + extends Embeddable { + + private async fetchData() { + // Every embeddable receives an optional `searchSessionId` input parameter. + const { searchSessionId } = this.input; + + // Setup your search source + this.configureSearchSource(); + + try { + // Mark the embeddable as loading + this.updateOutput({ loading: true, error: undefined }); + + // Make the request, wait for the final result + const resp = await searchSource.fetch$({ + sessionId: searchSessionId, + }).toPromise(); + + this.useSearchResult(resp); + + this.updateOutput({ loading: false, error: undefined }); + } catch (error) { + // handle search errors + this.updateOutput({ loading: false, error }); + } + } +} + +``` + +You can also retrieve the active `Search Session ID` from the `Search Service` directly: + +```ts +async function fetchData(data: DataPublicPluginStart) { + try { + return await searchSource.fetch$({ + sessionId: data.search.sessions.getSessionId(), + }).toPromise(); + } catch (e) { + // handle search errors + } +} + +``` + + + Search sessions are initiated by the client. If you are using a route that runs server side searches, you can send the `searchSessionId` to the server, and then pass it down to the server side `data.search` function call. + + +#### Supporting search sessions in your application + +Before implementing the ability to create and restore search sessions in your application, ask yourself the following questions: + +1. **Does your application normally run long operations?** For example, it makes sense for a user to generate a Dashboard or a Canvas report from data stored in cold storage. However, when editing a single visualization, it is best to work with a shorter timeframe of hot or warm data. +2. **Does it make sense for your application to restore a search session?** For example, you might want to restore an interesting configuration of filters of older documents you found in Discover. However, a single Lens or Map visualization might not be as helpful, outside the context of a specific dashboard. +3. **What is a search session in the context of your application?** Although Discover and Dashboard start a new search session every time the time range or filters change, or when the user clicks **Refresh**, you can manage your sessions differently. For example, if your application has tabs, you might group searches from multiple tabs into a single search session. You must be able to clearly define the **state** used to create the search session`. The **state** refers to any setting that might change the queries being set to `Elasticsearch`. + +Once you answer those questions, proceed to implement the following bits of code in your application. + +##### Provide storage configuration + +In your plugin's `start` lifecycle method, call the `enableStorage` method. This method helps the `Session Service` gather the information required to save the search sessions upon a user's request and construct the restore state: + +```ts +export class MyPlugin implements Plugin { + public start(core: CoreStart, { data }: MyPluginStartDependencies) { + const sessionRestorationDataProvider: SearchSessionInfoProvider = { + data, + getDashboard + } + + data.search.session.enableStorage({ + getName: async () => { + // return the name you want to give the saved Search Session + return `MyApp_${Math.random()}`; + }, + getUrlGeneratorData: async () => { + return { + urlGeneratorId: MY_URL_GENERATOR, + initialState: getUrlGeneratorState({ ...deps, shouldRestoreSearchSession: false }), + restoreState: getUrlGeneratorState({ ...deps, shouldRestoreSearchSession: true }), + }; + }, + }); + } +} +``` + + + The restore state of a search session may be different from the initial state used to create it. For example, where the initial state may contain relative dates, in the restore state, those must be converted to absolute dates. Read more about the [NowProvider](). + + + + Calling `enableStorage` will also enable the `Search Session Indicator` component in the chrome component of your solution. The `Search Session Indicator` is a small button, used by default to engage users and save new search sessions. To implement your own UI, contact the Kibana application services team to decouple this behavior. + + +##### Start a new search session + +Make sure to call `start` when the **state** you previously defined changes. + +```ts + +function onSearchSessionConfigChange() { + this.searchSessionId = data.search.sessions.start(); +} + +``` + +Pass the `searchSessionId` to every `search` call inside your application. If you're using `Embeddables`, pass down the `searchSessionId` as `input`. + +If you can't pass the `searchSessionId` directly, you can retrieve it from the service. + +```ts +const currentSearchSessionId = data.search.sessions.getSessionId(); + +``` + +##### Clear search sessions + +Creating a new search session clears the previous one. You must explicitly `clear` the search session when your application is being destroyed: + +```ts +function onDestroy() { + data.search.session.clear(); +} + +``` + +If you don't call `clear`, you will see a warning in the console while developing. However, when running in production, you will get a fatal error. This is done to avoid leakage of unrelated search requests into an existing search session left open by mistake. + +##### Restore search sessions + +The last step of the integration is restoring an existing search session. The `searchSessionId` parameter and the rest of the restore state are passed into the application via the URL. Non-URL support is planned for future releases. + +If you detect the presense of a `searchSessionId` parameter in the URL, call the `restore` method **instead** of calling `start`. The previous example would now become: + +```ts + +function onSearchSessionConfigChange(searchSessionIdFromUrl?: string) { + if (searchSessionIdFromUrl) { + data.search.sessions.restore(searchSessionIdFromUrl); + } else { + data.search.sessions.start(); + } +} + +``` + +Once you `restore` the session, as long as all `search` requests run with the same `searchSessionId`, the search session should be seamlessly restored. + +##### Customize the user experience + +TBD diff --git a/docs/CHANGELOG.asciidoc b/docs/CHANGELOG.asciidoc index 4163883b0f804..f92c88c73c77b 100644 --- a/docs/CHANGELOG.asciidoc +++ b/docs/CHANGELOG.asciidoc @@ -258,6 +258,7 @@ Machine Learning:: * Adds runtime_mappings to job wizards {kibana-pull}85817[#85817] Management:: * Allows custom name for fields via index pattern field management {kibana-pull}70039[#70039] +* In index pattern management - Refresh button removed as index pattern field lists are refreshed when index patterns are loaded, such as on page load or when moving between kibana apps {kibana-pull}82223[#82223] * Painless Lab in DevTools now supports autocompletion for keywords, and class and class members for the Painless language based on a given context {kibana-pull}80577[#80577] * Transforms: Remove index field limitation for custom query {kibana-pull}81467[#81467] * In the Index Management app, you can now click a data stream index lifecycle policy and view it in the Index Lifecycle Policies app {kibana-pull}82165[#82165] diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index 6a6c840074f02..eaaf68eb06195 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -1697,6 +1697,16 @@ Aliases: `column`, `name` Aliases: `exp`, `fn`, `function` |`boolean`, `number`, `string`, `null` |A Canvas expression that is passed to each row as a single row `datatable`. + +|`id` + +|`string`, `null` +|An optional id of the resulting column. When not specified or `null` the name argument is used as id. + +|`copyMetaFrom` + +|`string`, `null` +|If set, the meta object from the specified column id is copied over to the specified target column. Throws an exception if the column doesn't exist |=== *Returns:* `datatable` @@ -1755,9 +1765,16 @@ Interprets a `TinyMath` math expression using a `number` or `datatable` as _cont Alias: `expression` |`string` |An evaluated `TinyMath` expression. See https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html. + +|`onError` + +|`string` +|In case the `TinyMath` evaluation fails or returns NaN, the return value is specified by onError. For example, `"null"`, `"zero"`, `"false"`, `"throw"`. When `"throw"`, it will throw an exception, terminating expression execution. + +Default: `"throw"` |=== -*Returns:* `number` +*Returns:* `number` | `boolean` | `null` [float] diff --git a/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md b/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md index a50df950628b3..f6de959589eca 100644 --- a/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md +++ b/docs/development/core/server/kibana-plugin-core-server.appenderconfigtype.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RollingFileAppenderConfig; +export declare type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md index b0182a7c48e16..bd15b95d73ace 100644 --- a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md @@ -28,6 +28,7 @@ Should never be used in code outside of Core but is exported for documentation p | [requiredBundles](./kibana-plugin-core-server.pluginmanifest.requiredbundles.md) | readonly string[] | List of plugin ids that this plugin's UI code imports modules from that are not in requiredPlugins. | | [requiredPlugins](./kibana-plugin-core-server.pluginmanifest.requiredplugins.md) | readonly PluginName[] | An optional list of the other plugins that \*\*must be\*\* installed and enabled for this plugin to function properly. | | [server](./kibana-plugin-core-server.pluginmanifest.server.md) | boolean | Specifies whether plugin includes some server-side specific functionality. | +| [serviceFolders](./kibana-plugin-core-server.pluginmanifest.servicefolders.md) | readonly string[] | Only used for the automatically generated API documentation. Specifying service folders will cause your plugin API reference to be broken up into sub sections. | | [ui](./kibana-plugin-core-server.pluginmanifest.ui.md) | boolean | Specifies whether plugin includes some client/browser specific functionality that should be included into client bundle via public/ui_plugin.js file. | | [version](./kibana-plugin-core-server.pluginmanifest.version.md) | string | Version of the plugin. | diff --git a/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.servicefolders.md b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.servicefolders.md new file mode 100644 index 0000000000000..8ee33bdfa0f3f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.servicefolders.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) > [serviceFolders](./kibana-plugin-core-server.pluginmanifest.servicefolders.md) + +## PluginManifest.serviceFolders property + +Only used for the automatically generated API documentation. Specifying service folders will cause your plugin API reference to be broken up into sub sections. + +Signature: + +```typescript +readonly serviceFolders?: readonly string[]; +``` diff --git a/docs/discover/images/discover-search-for-relevance.png b/docs/discover/images/discover-search-for-relevance.png new file mode 100644 index 0000000000000..4d59ad186ded4 Binary files /dev/null and b/docs/discover/images/discover-search-for-relevance.png differ diff --git a/docs/discover/search-for-relevance.asciidoc b/docs/discover/search-for-relevance.asciidoc new file mode 100644 index 0000000000000..0232c02b6e061 --- /dev/null +++ b/docs/discover/search-for-relevance.asciidoc @@ -0,0 +1,24 @@ +[[discover-search-for-relevance]] +== Search for relevance +Sometimes you might be unsure which documents best match your question. +{es} assigns a relevancy, or score to each document, so you can +can narrow your search to the documents with the most relevant results. +The higher the score, the better it matches your query. + +For example, suppose you have the <>, and you're a searching for +a flight that arrived or departed from `Warsaw` or `Venice` when the weather was clear. + +. In *Discover*, open the index pattern dropdown, and select `kibana_sample_data_flight`. +. In the query bar, click *KQL*, and switch to the <>. +. Search for `Warsaw OR Venice OR Clear`. +. If you don't see any results, open the time filter and select a time range that contains data. +. From the list of *Available fields*, add `_score` to the document table. +. In the document table, click the header for the `_score` column, and then sort the column by descending scores. ++ +The results are currently sorted by first `Time`, and then by `_score`. +. To sort only by `_score`, remove the `Time` field. ++ +Your table now shows documents with the best matches, from most to least relevant. ++ +[role="screenshot"] +image::images/discover-search-for-relevance.png["Example of a search for relevance"] diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index c3185aaad553a..cadf8e0b16a44 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -105,7 +105,7 @@ See <> for how to obtain the endpoint and + * The action’s type: Trigger, Resolve, or Acknowledge. * The event’s severity: Info, warning, error, or critical. -* An array of different fields, including the timestamp, group, class, component, and your dedup key. +* An array of different fields, including the timestamp, group, class, component, and your dedup key. By default, the dedup is configured to create a new PagerDuty incident for each alert instance and reuse the incident when a recovered alert instance reactivates. Depending on your custom needs, assign them variables from the alerting context. To see the available context variables, click on the *Add alert variable* icon next to each corresponding field. For more details on these parameters, see the @@ -179,7 +179,7 @@ PagerDuty actions have the following properties: Severity:: The perceived severity of on the affected system. This can be one of `Critical`, `Error`, `Warning` or `Info`(default). Event action:: One of `Trigger` (default), `Resolve`, or `Acknowledge`. See https://v2.developer.pagerduty.com/docs/events-api-v2#event-action[event action] for more details. -Dedup Key:: All actions sharing this key will be associated with the same PagerDuty alert. This value is used to correlate trigger and resolution. This value is *optional*, and if unset defaults to `action:`. The maximum length is *255* characters. See https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication[alert deduplication] for details. +Dedup Key:: All actions sharing this key will be associated with the same PagerDuty alert. This value is used to correlate trigger and resolution. This value is *optional*, and if not set, defaults to `:`. The maximum length is *255* characters. See https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication[alert deduplication] for details. Timestamp:: An *optional* https://v2.developer.pagerduty.com/v2/docs/types#datetime[ISO-8601 format date-time], indicating the time the event was detected or generated. Component:: An *optional* value indicating the component of the source machine that is responsible for the event, for example `mysql` or `eth0`. Group:: An *optional* value indicating the logical grouping of components of a service, for example `app-stack`. diff --git a/docs/user/alerting/alert-types.asciidoc b/docs/user/alerting/alert-types.asciidoc index 993d815c37f71..5983804c5c862 100644 --- a/docs/user/alerting/alert-types.asciidoc +++ b/docs/user/alerting/alert-types.asciidoc @@ -30,6 +30,14 @@ For domain-specific alerts, refer to the documentation for that app. * <> * <> +[NOTE] +============================================== +Some alert types are subscription features, while others are free features. +For a comparison of the Elastic subscription levels, +see {subscriptions}[the subscription page]. +============================================== + + include::stack-alerts/index-threshold.asciidoc[] include::stack-alerts/es-query.asciidoc[] include::maps-alerts/geo-alert-types.asciidoc[] diff --git a/docs/user/discover.asciidoc b/docs/user/discover.asciidoc index a08713421a7fb..42ac1e22ce167 100644 --- a/docs/user/discover.asciidoc +++ b/docs/user/discover.asciidoc @@ -198,7 +198,6 @@ image:images/visualize-from-discover.png[Visualization that opens from Discover [float] === What’s next? - * <>. * <> to better meet your needs. @@ -209,6 +208,8 @@ the table columns that display by default, and more. * <>. +* <>. + -- include::{kib-repo-dir}/management/index-patterns.asciidoc[] @@ -216,3 +217,5 @@ include::{kib-repo-dir}/management/index-patterns.asciidoc[] include::{kib-repo-dir}/discover/set-time-filter.asciidoc[] include::{kib-repo-dir}/discover/search.asciidoc[] + +include::{kib-repo-dir}/discover/search-for-relevance.asciidoc[] diff --git a/package.json b/package.json index 0aec22e7e8222..1006d1bfcaace 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "test:ftr:runner": "node scripts/functional_test_runner", "checkLicenses": "node scripts/check_licenses --dev", "build": "node scripts/build --all-platforms", + "build:apidocs": "node scripts/build_api_docs", "start": "node scripts/kibana --dev", "debug": "node --nolazy --inspect scripts/kibana --dev", "debug-break": "node --nolazy --inspect-brk scripts/kibana --dev", @@ -71,11 +72,11 @@ "**/cross-fetch/node-fetch": "^2.6.1", "**/deepmerge": "^4.2.2", "**/fast-deep-equal": "^3.1.1", - "**/graphql-toolkit/lodash": "^4.17.15", + "**/graphql-toolkit/lodash": "^4.17.21", "**/hoist-non-react-statics": "^3.3.2", "**/isomorphic-fetch/node-fetch": "^2.6.1", "**/istanbul-instrumenter-loader/schema-utils": "1.0.0", - "**/load-grunt-config/lodash": "^4.17.20", + "**/load-grunt-config/lodash": "^4.17.21", "**/minimist": "^1.2.5", "**/node-jose/node-forge": "^0.10.0", "**/prismjs": "1.22.0", @@ -230,7 +231,7 @@ "json-stringify-safe": "5.0.1", "jsonwebtoken": "^8.5.1", "load-json-file": "^6.2.0", - "lodash": "^4.17.20", + "lodash": "^4.17.21", "lru-cache": "^4.1.5", "markdown-it": "^10.0.0", "md5": "^2.1.0", @@ -364,7 +365,7 @@ "@kbn/plugin-generator": "link:packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", - "@kbn/release-notes": "link:packages/kbn-release-notes", + "@kbn/docs-utils": "link:packages/kbn-docs-utils", "@kbn/storybook": "link:packages/kbn-storybook", "@kbn/telemetry-tools": "link:packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", @@ -387,7 +388,6 @@ "@storybook/addon-essentials": "^6.0.26", "@storybook/addon-knobs": "^6.0.26", "@storybook/addon-storyshots": "^6.0.26", - "@storybook/addons": "^6.0.16", "@storybook/components": "^6.0.26", "@storybook/core": "^6.0.26", "@storybook/core-events": "^6.0.26", @@ -819,6 +819,7 @@ "tinycolor2": "1.4.1", "topojson-client": "3.0.0", "ts-loader": "^7.0.5", + "ts-morph": "^9.1.0", "tsd": "^0.13.1", "typescript": "4.1.3", "typescript-fsa": "^3.0.0", diff --git a/packages/kbn-dev-utils/src/plugins/parse_kibana_platform_plugin.ts b/packages/kbn-dev-utils/src/plugins/parse_kibana_platform_plugin.ts index 62a6f1d347c9b..8d3fdb0f390c5 100644 --- a/packages/kbn-dev-utils/src/plugins/parse_kibana_platform_plugin.ts +++ b/packages/kbn-dev-utils/src/plugins/parse_kibana_platform_plugin.ts @@ -29,6 +29,7 @@ interface Manifest { server: boolean; kibanaVersion: string; version: string; + serviceFolders: readonly string[]; requiredPlugins: readonly string[]; optionalPlugins: readonly string[]; requiredBundles: readonly string[]; @@ -64,6 +65,7 @@ export function parseKibanaPlatformPlugin(manifestPath: string): KibanaPlatformP id: manifest.id, version: manifest.version, kibanaVersion: manifest.kibanaVersion || manifest.version, + serviceFolders: manifest.serviceFolders || [], requiredPlugins: isValidDepsDeclaration(manifest.requiredPlugins, 'requiredPlugins'), optionalPlugins: isValidDepsDeclaration(manifest.optionalPlugins, 'optionalPlugins'), requiredBundles: isValidDepsDeclaration(manifest.requiredBundles, 'requiredBundles'), diff --git a/packages/kbn-release-notes/jest.config.js b/packages/kbn-docs-utils/jest.config.js similarity index 89% rename from packages/kbn-release-notes/jest.config.js rename to packages/kbn-docs-utils/jest.config.js index db5e48b1704fb..e9cdc1978231e 100644 --- a/packages/kbn-release-notes/jest.config.js +++ b/packages/kbn-docs-utils/jest.config.js @@ -9,5 +9,5 @@ module.exports = { preset: '@kbn/test', rootDir: '../..', - roots: ['/packages/kbn-release-notes'], + roots: ['/packages/kbn-docs-utils'], }; diff --git a/packages/kbn-release-notes/package.json b/packages/kbn-docs-utils/package.json similarity index 85% rename from packages/kbn-release-notes/package.json rename to packages/kbn-docs-utils/package.json index c325367c7d47e..089732e9e6b40 100644 --- a/packages/kbn-release-notes/package.json +++ b/packages/kbn-docs-utils/package.json @@ -1,5 +1,5 @@ { - "name": "@kbn/release-notes", + "name": "@kbn/docs-utils", "version": "1.0.0", "license": "SSPL-1.0 OR Elastic License 2.0", "private": "true", @@ -13,6 +13,7 @@ }, "dependencies": { "@kbn/utils": "link:../kbn-utils", + "@kbn/config": "link:../kbn-config", "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/README.md b/packages/kbn-docs-utils/src/api_docs/README.md new file mode 100644 index 0000000000000..f980fe83b9596 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/README.md @@ -0,0 +1,12 @@ +# Autogenerated API documentation + +[RFC](../../../rfcs/text/0014_api_documentation.md) + +This is an experimental api documentation system that is managed by the Kibana Tech Leads until +we determine the value of such a system and what kind of maintenance burder it will incur. + +To generate the docs run + +``` +node scripts/build_api_docs +``` diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/buid_api_declaration.test.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/buid_api_declaration.test.ts new file mode 100644 index 0000000000000..31e0d59dcca8a --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/buid_api_declaration.test.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import { Project, Node } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; + +import { TypeKind, ApiScope } from '../types'; +import { getKibanaPlatformPlugin } from '../tests/kibana_platform_plugin_mock'; +import { getDeclarationNodesForPluginScope } from '../get_declaration_nodes_for_plugin'; +import { buildApiDeclaration } from './build_api_declaration'; +import { isNamedNode } from '../tsmorph_utils'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +let nodes: Node[]; +let plugins: KibanaPlatformPlugin[]; + +function getNodeName(node: Node): string { + return isNamedNode(node) ? node.getName() : ''; +} + +beforeAll(() => { + const tsConfigFilePath = Path.resolve(__dirname, '../tests/__fixtures__/src/tsconfig.json'); + const project = new Project({ + tsConfigFilePath, + }); + + plugins = [getKibanaPlatformPlugin('pluginA')]; + + nodes = getDeclarationNodesForPluginScope(project, plugins[0], ApiScope.CLIENT, log); +}); + +it('Test number primitive doc def', () => { + const node = nodes.find((n) => getNodeName(n) === 'aNum'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + + expect(def.type).toBe(TypeKind.NumberKind); +}); + +it('Function type is exported as type with signature', () => { + const node = nodes.find((n) => getNodeName(n) === 'FnWithGeneric'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + expect(def).toBeDefined(); + expect(def?.type).toBe(TypeKind.TypeKind); + expect(def?.signature?.length).toBeGreaterThan(0); +}); + +it('Test Interface Kind doc def', () => { + const node = nodes.find((n) => getNodeName(n) === 'ExampleInterface'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + + expect(def.type).toBe(TypeKind.InterfaceKind); + expect(def.children).toBeDefined(); + expect(def.children!.length).toBe(3); +}); + +it('Test union export', () => { + const node = nodes.find((n) => getNodeName(n) === 'aUnionProperty'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + expect(def.type).toBe(TypeKind.CompoundTypeKind); +}); + +it('Function inside interface has a label', () => { + const node = nodes.find((n) => getNodeName(n) === 'ExampleInterface'); + expect(node).toBeDefined(); + const def = buildApiDeclaration(node!, plugins, log, plugins[0].manifest.id, ApiScope.CLIENT); + + const fn = def!.children?.find((c) => c.label === 'aFn'); + expect(fn).toBeDefined(); + expect(fn?.label).toBe('aFn'); + expect(fn?.type).toBe(TypeKind.FunctionKind); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts new file mode 100644 index 0000000000000..3ee6676cf5e32 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Node } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { buildClassDec } from './build_class_dec'; +import { buildFunctionDec } from './build_function_dec'; +import { getCommentsFromNode } from './js_doc_utils'; +import { isNamedNode } from '../tsmorph_utils'; +import { AnchorLink, ApiDeclaration } from '../types'; +import { buildVariableDec } from './build_variable_dec'; +import { getApiSectionId } from '../utils'; +import { getSourceForNode } from './utils'; +import { buildTypeLiteralDec } from './build_type_literal_dec'; +import { ApiScope } from '../types'; +import { getSignature } from './get_signature'; +import { buildInterfaceDec } from './build_interface_dec'; +import { getTypeKind } from './get_type_kind'; + +/** + * A potentially recursive function, depending on the node type, that builds a JSON like structure + * that can be passed to the elastic-docs component for rendering as an API. Nodes like classes, + * interfaces, objects and functions will have children for their properties, members and parameters. + * + * @param node The ts-morph node to build an ApiDeclaration for. + * @param plugins The list of plugins registered is used for building cross plugin links by looking up + * the plugin by import path. We could accomplish the same thing via a regex on the import path, but this lets us + * decouple plugin path from plugin id. + * @param log Logs messages to console. + * @param pluginName The name of the plugin this declaration belongs to. + * @param scope The scope this declaration belongs to (server, public, or common). + * @param parentApiId If this declaration is nested inside another declaration, it should have a parent id. This + * is used to create the anchor link to this API item. + * @param name An optional name to pass through which will be used instead of node.getName, if it + * exists. For some types, like Parameters, the name comes on the parent node, but we want the doc def + * to be built from the TypedNode + */ +export function buildApiDeclaration( + node: Node, + plugins: KibanaPlatformPlugin[], + log: ToolingLog, + pluginName: string, + scope: ApiScope, + parentApiId?: string, + name?: string +): ApiDeclaration { + const apiName = name ? name : isNamedNode(node) ? node.getName() : 'Unnamed'; + log.debug(`Building API Declaration for ${apiName} of kind ${node.getKindName()}`); + const apiId = parentApiId ? parentApiId + '.' + apiName : apiName; + const anchorLink: AnchorLink = { scope, pluginName, apiName: apiId }; + + if (Node.isClassDeclaration(node)) { + return buildClassDec(node, plugins, anchorLink, log); + } else if (Node.isInterfaceDeclaration(node)) { + return buildInterfaceDec(node, plugins, anchorLink, log); + } else if ( + Node.isMethodSignature(node) || + Node.isFunctionDeclaration(node) || + Node.isMethodDeclaration(node) || + Node.isConstructorDeclaration(node) + ) { + return buildFunctionDec(node, plugins, anchorLink, log); + } else if ( + Node.isPropertySignature(node) || + Node.isPropertyDeclaration(node) || + Node.isShorthandPropertyAssignment(node) || + Node.isPropertyAssignment(node) || + Node.isVariableDeclaration(node) + ) { + return buildVariableDec(node, plugins, anchorLink, log); + } else if (Node.isTypeLiteralNode(node)) { + return buildTypeLiteralDec(node, plugins, anchorLink, log, apiName); + } + + return { + id: getApiSectionId(anchorLink), + type: getTypeKind(node), + label: apiName, + description: getCommentsFromNode(node), + source: getSourceForNode(node), + signature: getSignature(node, plugins, log), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts new file mode 100644 index 0000000000000..146fcf4fa4d0a --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; + +import { + ArrowFunction, + VariableDeclaration, + PropertyDeclaration, + PropertySignature, + ShorthandPropertyAssignment, + PropertyAssignment, +} from 'ts-morph'; +import { getApiSectionId } from '../utils'; +import { getCommentsFromNode } from './js_doc_utils'; +import { AnchorLink, TypeKind } from '../types'; +import { getSourceForNode } from './utils'; +import { buildApiDecsForParameters } from './build_parameter_decs'; +import { getSignature } from './get_signature'; +import { getJSDocReturnTagComment } from './js_doc_utils'; + +/** + * Arrow functions are handled differently than regular functions because you need the arrow function + * initializer as well as the node. The initializer is where the parameters are grabbed from and the + * signature, while the node has the comments and name. + * + * @param node + * @param initializer + * @param plugins + * @param anchorLink + * @param log + */ +export function getArrowFunctionDec( + node: + | VariableDeclaration + | PropertyDeclaration + | PropertySignature + | ShorthandPropertyAssignment + | PropertyAssignment, + initializer: ArrowFunction, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +) { + log.debug( + `Getting Arrow Function doc def for node ${node.getName()} of kind ${node.getKindName()}` + ); + return { + id: getApiSectionId(anchorLink), + type: TypeKind.FunctionKind, + children: buildApiDecsForParameters(initializer.getParameters(), plugins, anchorLink, log), + signature: getSignature(initializer, plugins, log), + description: getCommentsFromNode(node), + label: node.getName(), + source: getSourceForNode(node), + returnComment: getJSDocReturnTagComment(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_class_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_class_dec.ts new file mode 100644 index 0000000000000..2ccce506dde53 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_class_dec.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { ClassDeclaration } from 'ts-morph'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { getCommentsFromNode } from './js_doc_utils'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getSourceForNode, isPrivate } from './utils'; +import { getApiSectionId } from '../utils'; +import { getSignature } from './get_signature'; + +export function buildClassDec( + node: ClassDeclaration, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +): ApiDeclaration { + return { + id: getApiSectionId(anchorLink), + type: TypeKind.ClassKind, + label: node.getName() || 'Missing label', + description: getCommentsFromNode(node), + signature: getSignature(node, plugins, log), + children: node.getMembers().reduce((acc, m) => { + if (!isPrivate(m)) { + acc.push( + buildApiDeclaration( + m, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName + ) + ); + } + return acc; + }, [] as ApiDeclaration[]), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts new file mode 100644 index 0000000000000..2936699152a83 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + FunctionDeclaration, + MethodDeclaration, + ConstructorDeclaration, + Node, + MethodSignature, +} from 'ts-morph'; + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { buildApiDecsForParameters } from './build_parameter_decs'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { getCommentsFromNode } from './js_doc_utils'; +import { getApiSectionId } from '../utils'; +import { getJSDocReturnTagComment, getJSDocs, getJSDocTagNames } from './js_doc_utils'; +import { getSourceForNode } from './utils'; +import { getSignature } from './get_signature'; + +/** + * Takes the various function-like node declaration types and converts them into an ApiDeclaration. + * @param node + * @param plugins + * @param anchorLink + * @param log + */ +export function buildFunctionDec( + node: FunctionDeclaration | MethodDeclaration | ConstructorDeclaration | MethodSignature, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +): ApiDeclaration { + const label = Node.isConstructorDeclaration(node) + ? 'Constructor' + : node.getName() || '(WARN: Missing name)'; + log.debug(`Getting function doc def for node ${label} of kind ${node.getKindName()}`); + return { + id: getApiSectionId(anchorLink), + type: TypeKind.FunctionKind, + label, + signature: getSignature(node, plugins, log), + description: getCommentsFromNode(node), + children: buildApiDecsForParameters( + node.getParameters(), + plugins, + anchorLink, + log, + getJSDocs(node) + ), + tags: getJSDocTagNames(node), + returnComment: getJSDocReturnTagComment(node), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_type_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_type_dec.ts new file mode 100644 index 0000000000000..3cd4317e23f0c --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_type_dec.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { FunctionTypeNode, JSDoc } from 'ts-morph'; +import { getApiSectionId } from '../utils'; +import { getCommentsFromNode } from './js_doc_utils'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { buildApiDecsForParameters } from './build_parameter_decs'; +import { extractImportReferences } from './extract_import_refs'; +import { getJSDocReturnTagComment, getJSDocs, getJSDocTagNames } from './js_doc_utils'; +import { getSourceForNode } from './utils'; + +export function buildApiDecFromFunctionType( + name: string, + node: FunctionTypeNode, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog, + jsDocs?: JSDoc[] +): ApiDeclaration { + log.debug(`Getting Function Type doc def for node ${name} of kind ${node.getKindName()}`); + return { + type: TypeKind.FunctionKind, + id: getApiSectionId(anchorLink), + label: name, + signature: extractImportReferences(node.getType().getText(), plugins, log), + description: getCommentsFromNode(node), + tags: jsDocs ? getJSDocTagNames(jsDocs) : [], + returnComment: jsDocs ? getJSDocReturnTagComment(jsDocs) : [], + children: buildApiDecsForParameters( + node.getParameters(), + plugins, + anchorLink, + log, + jsDocs || getJSDocs(node) + ), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_interface_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_interface_dec.ts new file mode 100644 index 0000000000000..2329aa2190d95 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_interface_dec.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { InterfaceDeclaration } from 'ts-morph'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { getCommentsFromNode } from './js_doc_utils'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getSourceForNode } from './utils'; +import { getApiSectionId } from '../utils'; +import { getSignature } from './get_signature'; + +export function buildInterfaceDec( + node: InterfaceDeclaration, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +): ApiDeclaration { + return { + id: getApiSectionId(anchorLink), + type: TypeKind.InterfaceKind, + label: node.getName(), + signature: getSignature(node, plugins, log), + description: getCommentsFromNode(node), + children: node + .getMembers() + .map((m) => + buildApiDeclaration( + m, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName + ) + ), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_parameter_decs.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_parameter_decs.ts new file mode 100644 index 0000000000000..e420f76357f75 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_parameter_decs.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ParameterDeclaration, JSDoc, SyntaxKind } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { extractImportReferences } from './extract_import_refs'; +import { AnchorLink, ApiDeclaration } from '../types'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getJSDocParamComment } from './js_doc_utils'; +import { getSourceForNode } from './utils'; +import { getTypeKind } from './get_type_kind'; + +/** + * A helper function to capture function parameters, whether it comes from an arrow function, a regular function or + * a function type. + * + * @param params + * @param plugins + * @param anchorLink + * @param log + * @param jsDocs + */ +export function buildApiDecsForParameters( + params: ParameterDeclaration[], + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog, + jsDocs?: JSDoc[] +): ApiDeclaration[] { + return params.reduce((acc, param) => { + const label = param.getName(); + log.debug(`Getting parameter doc def for ${label} of kind ${param.getKindName()}`); + // Literal types are non primitives that aren't references to other types. We add them as a more + // defined node, with children. + // If we don't want the docs to be too deeply nested we could avoid this special handling. + if (param.getTypeNode() && param.getTypeNode()!.getKind() === SyntaxKind.TypeLiteral) { + acc.push( + buildApiDeclaration( + param.getTypeNode()!, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName, + label + ) + ); + } else { + acc.push({ + type: getTypeKind(param), + label, + isRequired: param.getType().isNullable() === false, + signature: extractImportReferences(param.getType().getText(), plugins, log), + description: jsDocs ? getJSDocParamComment(jsDocs, label) : [], + source: getSourceForNode(param), + }); + } + return acc; + }, [] as ApiDeclaration[]); +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_type_literal_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_type_literal_dec.ts new file mode 100644 index 0000000000000..39d47d5bacba1 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_type_literal_dec.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { TypeLiteralNode } from 'ts-morph'; +import { getApiSectionId } from '../utils'; +import { getCommentsFromNode } from './js_doc_utils'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getSourceForNode } from './utils'; + +/** + * This captures function parameters that are object types, and makes sure their + * properties are recursively walked so they are expandable in the docs. + * + * The test verifying `crazyFunction` will fail without this special handling. + * + * @param node + * @param plugins + * @param anchorLink + * @param log + * @param name + */ +export function buildTypeLiteralDec( + node: TypeLiteralNode, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog, + name: string +): ApiDeclaration { + return { + id: getApiSectionId(anchorLink), + type: TypeKind.ObjectKind, + label: name, + description: getCommentsFromNode(node), + children: node + .getMembers() + .map((m) => + buildApiDeclaration( + m, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName + ) + ), + source: getSourceForNode(node), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts new file mode 100644 index 0000000000000..3e0b48de1e18b --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { + VariableDeclaration, + Node, + PropertyAssignment, + PropertyDeclaration, + PropertySignature, + ShorthandPropertyAssignment, +} from 'ts-morph'; +import { getApiSectionId } from '../utils'; +import { getCommentsFromNode } from './js_doc_utils'; +import { AnchorLink, ApiDeclaration, TypeKind } from '../types'; +import { getArrowFunctionDec } from './build_arrow_fn_dec'; +import { buildApiDeclaration } from './build_api_declaration'; +import { getSourceForNode } from './utils'; +import { getSignature } from './get_signature'; +import { getTypeKind } from './get_type_kind'; + +/** + * Special handling for objects and arrow functions which are variable or property node types. + * Objects and arrow functions need their children extracted recursively. This uses the name from the + * node, but checks for an initializer to get inline arrow functions and objects defined recursively. + * + * @param node + * @param plugins + * @param anchorLink + * @param log + */ +export function buildVariableDec( + node: + | VariableDeclaration + | PropertyAssignment + | PropertyDeclaration + | PropertySignature + | ShorthandPropertyAssignment, + plugins: KibanaPlatformPlugin[], + anchorLink: AnchorLink, + log: ToolingLog +): ApiDeclaration { + log.debug('buildVariableDec for ' + node.getName()); + const initializer = node.getInitializer(); + // Recusively list object properties as children. + if (initializer && Node.isObjectLiteralExpression(initializer)) { + return { + id: getApiSectionId(anchorLink), + type: TypeKind.ObjectKind, + children: initializer.getProperties().map((prop) => { + return buildApiDeclaration( + prop, + plugins, + log, + anchorLink.pluginName, + anchorLink.scope, + anchorLink.apiName + ); + }), + description: getCommentsFromNode(node), + label: node.getName(), + source: getSourceForNode(node), + }; + } else if (initializer && Node.isArrowFunction(initializer)) { + return getArrowFunctionDec(node, initializer, plugins, anchorLink, log); + } + + // Otherwise return it just as a single entry. + return { + id: getApiSectionId(anchorLink), + type: getTypeKind(node), + label: node.getName(), + description: getCommentsFromNode(node), + source: getSourceForNode(node), + signature: getSignature(node, plugins, log), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts new file mode 100644 index 0000000000000..a757df2ece366 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { getPluginApiDocId } from '../utils'; +import { extractImportReferences } from './extract_import_refs'; +import { ApiScope, Reference } from '../types'; +import { getKibanaPlatformPlugin } from '../tests/kibana_platform_plugin_mock'; + +const plugin = getKibanaPlatformPlugin('pluginA'); +const plugins: KibanaPlatformPlugin[] = [plugin]; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +it('when there are no imports', () => { + const results = extractImportReferences(`(param: string) => Bar`, plugins, log); + expect(results.length).toBe(1); + expect(results[0]).toBe('(param: string) => Bar'); +}); + +it('test extractImportReference', () => { + const results = extractImportReferences( + `(param: string) => import("${plugin.directory}/public/bar").Bar`, + plugins, + log + ); + expect(results.length).toBe(2); + expect(results[0]).toBe('(param: string) => '); + expect(results[1]).toEqual({ + text: 'Bar', + docId: getPluginApiDocId('plugin_a', log), + section: 'def-public.Bar', + pluginId: 'pluginA', + scope: ApiScope.CLIENT, + }); +}); + +it('test extractImportReference with public folder nested under server folder', () => { + const results = extractImportReferences( + `import("${plugin.directory}/server/routes/public/bar").Bar`, + plugins, + log + ); + expect(results.length).toBe(1); + expect(results[0]).toEqual({ + text: 'Bar', + docId: getPluginApiDocId('plugin_a', log), + section: 'def-server.Bar', + pluginId: 'pluginA', + scope: ApiScope.SERVER, + }); +}); + +it('test extractImportReference with two imports', () => { + const results = extractImportReferences( + ``, + plugins, + log + ); + expect(results.length).toBe(5); + expect(results[0]).toBe(''); +}); + +it('test extractImportReference with unknown imports', () => { + const results = extractImportReferences( + ``, + plugins, + log + ); + expect(results.length).toBe(3); + expect(results[0]).toBe(''); +}); + +it('test single link', () => { + const results = extractImportReferences( + `import("${plugin.directory}/public/foo/index").FooFoo`, + plugins, + log + ); + expect(results.length).toBe(1); + expect((results[0] as Reference).text).toBe('FooFoo'); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts new file mode 100644 index 0000000000000..1147e15a1acb6 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { getApiSectionId, getPluginApiDocId, getPluginForPath } from '../utils'; +import { ApiScope, TextWithLinks } from '../types'; + +/** + * + * @param text A string that may include an API item that was imported from another file. For example: + * "export type foo = string | import("kibana/src/plugins/a_plugin/public/path").Bar". + * @param plugins The list of registered Kibana plugins. Used to get the plugin id, which is then used to create + * the DocLink to that plugin's page, based off the relative path of any imports. + * @param log Logging utility for debuging + * + * @returns An array structure that can be used to create react DocLinks. For example, the above text would return + * something like: + * [ "export type foo = string | ", // Just a string for the pretext + * { id: "a_plugin", section: "public.Bar", text: "Bar" } // An object with info to create the DocLink. + * ] + */ +export function extractImportReferences( + text: string, + plugins: KibanaPlatformPlugin[], + log: ToolingLog +): TextWithLinks { + const texts: TextWithLinks = []; + let pos = 0; + let textSegment: string | undefined = text; + const max = 5; + while (textSegment) { + pos++; + if (pos > max) break; + + const ref = extractImportRef(textSegment); + if (ref) { + const { name, path, index, length } = ref; + if (index !== 0) { + texts.push(textSegment.substr(0, index)); + } + const plugin = getPluginForPath(path, plugins); + + if (!plugin) { + if (path.indexOf('plugin') >= 0) { + log.warning('WARN: no plugin found for reference path ' + path); + } + // If we can't create a link for this, still remove the import("..."). part to make + // it easier to read. + const str = textSegment.substr(index + length - name.length, name.length); + if (str && str !== '') { + texts.push(str); + } + } else { + const section = getApiSectionId({ + pluginName: plugin.manifest.id, + scope: getScopeFromPath(path, plugin, log), + apiName: name, + }); + texts.push({ + pluginId: plugin.manifest.id, + scope: getScopeFromPath(path, plugin, log), + docId: getPluginApiDocId(plugin.manifest.id, log, { + serviceFolders: plugin.manifest.serviceFolders, + apiPath: path, + directory: plugin.directory, + }), + section, + text: name, + }); + } + textSegment = textSegment.substr(index + length); + } else { + if (textSegment && textSegment !== '') { + texts.push(textSegment); + } + textSegment = undefined; + } + } + return texts; +} + +function extractImportRef( + str: string +): { path: string; name: string; index: number; length: number } | undefined { + const groups = str.match(/import\("(.*?)"\)\.(\w*)/); + if (groups) { + const path = groups[1]; + const name = groups[2]; + const index = groups.index!; + const length = groups[0].length; + return { path, name, index, length }; + } +} + +/** + * + * @param path An absolute path to a file inside a plugin directory. + */ +function getScopeFromPath(path: string, plugin: KibanaPlatformPlugin, log: ToolingLog): ApiScope { + if (path.startsWith(`${plugin.directory}/public/`)) { + return ApiScope.CLIENT; + } else if (path.startsWith(`${plugin.directory}/server/`)) { + return ApiScope.SERVER; + } else if (path.startsWith(`${plugin.directory}/common/`)) { + return ApiScope.COMMON; + } else { + log.warning(`Unexpected path encountered ${path}`); + return ApiScope.COMMON; + } +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_signature.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_signature.ts new file mode 100644 index 0000000000000..b7df94a03638f --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_signature.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { Node, Type } from 'ts-morph'; +import { isNamedNode } from '../tsmorph_utils'; +import { Reference } from '../types'; +import { extractImportReferences } from './extract_import_refs'; +import { getTypeKind } from './get_type_kind'; + +/** + * Special logic for creating the signature based on the type of node. See https://github.com/dsherret/ts-morph/issues/923#issue-795332729 + * for some issues that have been encountered in getting these accurate. + * + * By passing node to `getText`, ala `node.getType().getText(node)`, all reference links + * will be lost. However, if you do _not_ pass node, there are quite a few situations where it returns a reference + * to itself and has no helpful information. + * + * @param node + * @param plugins + * @param log + */ +export function getSignature( + node: Node, + plugins: KibanaPlatformPlugin[], + log: ToolingLog +): Array | undefined { + let signature = ''; + // node.getType() on a TypeAliasDeclaration is just a reference to itself. If we don't special case this, then + // `export type Foo = string | number;` would show up with a signagure of `Foo` that is a link to itself, instead of + // `string | number`. + if (Node.isTypeAliasDeclaration(node)) { + signature = getSignatureForTypeAlias(node.getType(), log, node); + } else if (Node.isFunctionDeclaration(node)) { + // See https://github.com/dsherret/ts-morph/issues/907#issue-770284331. + // Unfortunately this has to be manually pieced together, or it comes up as "typeof TheFunction" + const params = node + .getParameters() + .map((p) => `${p.getName()}: ${p.getType().getText()}`) + .join(', '); + const returnType = node.getReturnType().getText(); + signature = `(${params}) => ${returnType}`; + } else if (Node.isInterfaceDeclaration(node) || Node.isClassDeclaration(node)) { + // Need to tack on manually any type parameters or "extends/implements" section. + const heritageClause = node + .getHeritageClauses() + .map((h) => { + const heritance = h.getText().indexOf('implements') > -1 ? 'implements' : 'extends'; + return `${heritance} ${h.getTypeNodes().map((n) => n.getType().getText())}`; + }) + .join(' '); + signature = `${node.getType().getText()}${heritageClause ? ' ' + heritageClause : ''}`; + } else { + // Here, 'node' is explicitly *not* passed in to `getText` otherwise arrow functions won't + // include reference links. Tests will break if you add it in here, or remove it from above. + // There is test coverage for all this oddness. + signature = node.getType().getText(); + } + + // Don't return the signature if it's the same as the type (string, string) + if (getTypeKind(node).toString() === signature) return undefined; + + const referenceLinks = extractImportReferences(signature, plugins, log); + + // Don't return the signature if it's a single self referential link. + if ( + isNamedNode(node) && + referenceLinks.length === 1 && + typeof referenceLinks[0] === 'object' && + (referenceLinks[0] as Reference).text === node.getName() + ) { + return undefined; + } + + return referenceLinks; +} + +/** + * Not all types are handled here, but does return links for the more common ones. + */ +function getSignatureForTypeAlias(type: Type, log: ToolingLog, node?: Node): string { + if (type.isUnion()) { + return type + .getUnionTypes() + .map((nestedType) => getSignatureForTypeAlias(nestedType, log)) + .join(' | '); + } else if (node && type.getCallSignatures().length >= 1) { + return type + .getCallSignatures() + .map((sig) => { + const params = sig + .getParameters() + .map((p) => `${p.getName()}: ${p.getTypeAtLocation(node).getText()}`) + .join(', '); + const returnType = sig.getReturnType().getText(); + return `(${params}) => ${returnType}`; + }) + .join(' '); + } else if (node) { + const symbol = node.getSymbol(); + if (symbol) { + const declarations = symbol + .getDeclarations() + .map((d) => d.getType().getText(node)) + .join(' '); + if (symbol.getDeclarations().length !== 1) { + log.error( + `Node is type alias declaration with more than one declaration. This is not handled! ${declarations} and node is ${node.getText()}` + ); + } + return declarations; + } + } + return type.getText(); +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_type_kind.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_type_kind.ts new file mode 100644 index 0000000000000..592833f40cebf --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/get_type_kind.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Type, Node } from 'ts-morph'; +import { TypeKind } from '../types'; + +export function getTypeKind(node: Node): TypeKind { + if (Node.isTypeAliasDeclaration(node)) { + return TypeKind.TypeKind; + } else { + return getTypeKindForType(node.getType()); + } +} + +function getTypeKindForType(type: Type): TypeKind { + // I think a string literal is also a string... but just in case, checking both. + if (type.isString() || type.isStringLiteral()) { + return TypeKind.StringKind; + } else if (type.isNumber() || type.isNumberLiteral()) { + return TypeKind.NumberKind; + + // I could be wrong about this logic. Does this existance of a call signature mean it's a function? + } else if (type.getCallSignatures().length > 0) { + return TypeKind.FunctionKind; + } else if (type.isArray()) { + // Arrays are also objects, check this first. + return TypeKind.ArrayKind; + } else if (type.isObject()) { + return TypeKind.ObjectKind; + } else if (type.isBoolean() || type.isBooleanLiteral()) { + return TypeKind.BooleanKind; + } else if (type.isEnum() || type.isEnumLiteral()) { + return TypeKind.EnumKind; + } else if (type.isUnion()) { + // Special handling for "type | undefined" which happens alot and should be represented in docs as + // "type", but with an "optional" flag. Anything more complicated will just be returned as a + // "CompoundType". + if (getIsTypeOptional(type) && type.getUnionTypes().length === 2) { + const otherType = type.getUnionTypes().find((u) => u.isUndefined() === false); + if (otherType) { + return getTypeKindForType(otherType); + } + } + } else if (type.isAny()) { + return TypeKind.AnyKind; + } else if (type.isUnknown()) { + return TypeKind.UnknownKind; + } + + if (type.isUnionOrIntersection()) { + return TypeKind.CompoundTypeKind; + } + + return TypeKind.Uncategorized; +} + +function getIsTypeOptional(type: Type): boolean { + if (type.isUnion()) { + const unions = type.getUnionTypes(); + return unions.find((u) => u.isUndefined()) !== undefined; + } else { + return false; + } +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/js_doc_utils.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/js_doc_utils.ts new file mode 100644 index 0000000000000..baac7153dfb75 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/js_doc_utils.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { JSDoc, JSDocTag, Node } from 'ts-morph'; +import { TextWithLinks } from '../types'; + +/** + * Extracts comments out of the node to use as the description. + */ +export function getCommentsFromNode(node: Node): TextWithLinks | undefined { + let comments: TextWithLinks | undefined; + const jsDocs = getJSDocs(node); + if (jsDocs) { + return getTextWithLinks(jsDocs.map((jsDoc) => jsDoc.getDescription()).join('\n')); + } else { + comments = getTextWithLinks( + node + .getLeadingCommentRanges() + .map((c) => c.getText()) + .join('\n') + ); + } + + return comments; +} + +export function getJSDocs(node: Node): JSDoc[] | undefined { + if (Node.isJSDocableNode(node)) { + return node.getJsDocs(); + } else if (Node.isVariableDeclaration(node)) { + const gparent = node.getParent()?.getParent(); + if (Node.isJSDocableNode(gparent)) { + return gparent.getJsDocs(); + } + } +} + +export function getJSDocReturnTagComment(node: Node | JSDoc[]): TextWithLinks { + const tags = getJSDocTags(node); + const returnTag = tags.find((tag) => Node.isJSDocReturnTag(tag)); + if (returnTag) return getTextWithLinks(returnTag.getComment()); + return []; +} + +export function getJSDocParamComment(node: Node | JSDoc[], name: string): TextWithLinks { + const tags = getJSDocTags(node); + const paramTag = tags.find((tag) => Node.isJSDocParameterTag(tag) && tag.getName() === name); + if (paramTag) return getTextWithLinks(paramTag.getComment()); + return []; +} + +export function getJSDocTagNames(node: Node | JSDoc[]): string[] { + return getJSDocTags(node).reduce((tags, tag) => { + if (tag.getTagName() !== 'param' && tag.getTagName() !== 'returns') { + tags.push(tag.getTagName()); + } + return tags; + }, [] as string[]); +} + +function getJSDocTags(node: Node | JSDoc[]): JSDocTag[] { + const jsDocs = node instanceof Array ? node : getJSDocs(node); + if (!jsDocs) return []; + + return jsDocs.reduce((tagsAcc, jsDoc) => { + tagsAcc.push(...jsDoc.getTags()); + return tagsAcc; + }, [] as JSDocTag[]); +} + +/** + * TODO. This feature is not implemented yet. It will be used to create links for comments + * that use {@link AnotherAPIItemInThisPlugin}. + * + * @param text + */ +function getTextWithLinks(text?: string): TextWithLinks { + if (text) return [text]; + else return []; + // TODO: + // Replace `@links` in comments with relative api links. +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts new file mode 100644 index 0000000000000..9efa96b6e9676 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import Path from 'path'; +import { REPO_ROOT, kibanaPackageJson } from '@kbn/utils'; +import { ParameterDeclaration, ClassMemberTypes, Node } from 'ts-morph'; +import { SourceLink } from '../types'; + +export function isPrivate(node: ParameterDeclaration | ClassMemberTypes): boolean { + return node.getModifiers().find((mod) => mod.getText() === 'private') !== undefined; +} + +/** + * Change the absolute path into a relative one. + */ +function getRelativePath(fullPath: string): string { + return Path.relative(REPO_ROOT, fullPath); +} + +export function getSourceForNode(node: Node): SourceLink { + const path = getRelativePath(node.getSourceFile().getFilePath()); + const lineNumber = node.getStartLineNumber(); + return { + path, + lineNumber, + link: `https://github.com/elastic/kibana/tree/${kibanaPackageJson.branch}${path}#L${lineNumber}`, + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts b/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts new file mode 100644 index 0000000000000..ac6d6088c25c0 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/build_api_docs_cli.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fs from 'fs'; +import Path from 'path'; + +import { REPO_ROOT, run } from '@kbn/dev-utils'; +import { Project } from 'ts-morph'; + +import { getPluginApi } from './get_plugin_api'; +import { writePluginDocs } from './mdx/write_plugin_mdx_docs'; +import { ApiDeclaration, PluginApi } from './types'; +import { findPlugins } from './find_plugins'; +import { removeBrokenLinks } from './utils'; + +export interface PluginInfo { + apiCount: number; + apiCountMissingComments: number; + id: string; + missingApiItems: string[]; +} + +export function runBuildApiDocsCli() { + run( + async ({ log }) => { + const project = getTsProject(REPO_ROOT); + + const plugins = findPlugins(); + + const pluginInfos: { + [key: string]: PluginInfo; + } = {}; + + const outputFolder = Path.resolve(REPO_ROOT, 'api_docs'); + if (!Fs.existsSync(outputFolder)) { + Fs.mkdirSync(outputFolder); + } else { + // Delete all files except the README that warns about the auto-generated nature of + // the folder. + const files = Fs.readdirSync(outputFolder); + files.forEach((file) => { + if (file.indexOf('README.md') < 0) { + Fs.rmSync(Path.resolve(outputFolder, file)); + } + }); + } + + const pluginApiMap: { [key: string]: PluginApi } = {}; + plugins.map((plugin) => { + pluginApiMap[plugin.manifest.id] = getPluginApi(project, plugin, plugins, log); + }); + + const missingApiItems: { [key: string]: string[] } = {}; + + plugins.forEach((plugin) => { + const id = plugin.manifest.id; + const pluginApi = pluginApiMap[id]; + removeBrokenLinks(pluginApi, missingApiItems, pluginApiMap); + }); + + plugins.forEach((plugin) => { + const id = plugin.manifest.id; + const pluginApi = pluginApiMap[id]; + const info = { + id, + apiCount: countApiForPlugin(pluginApi), + apiCountMissingComments: countMissingCommentsApiForPlugin(pluginApi), + missingApiItems: missingApiItems[id], + }; + + if (info.apiCount > 0) { + writePluginDocs(outputFolder, pluginApi, log); + pluginInfos[id] = info; + } + }); + + // eslint-disable-next-line no-console + console.table(pluginInfos); + }, + { + log: { + defaultLevel: 'debug', + }, + } + ); +} + +function getTsProject(repoPath: string) { + const xpackTsConfig = `${repoPath}/tsconfig.json`; + const project = new Project({ + tsConfigFilePath: xpackTsConfig, + }); + project.addSourceFilesAtPaths(`${repoPath}/x-pack/plugins/**/*{.d.ts,.ts}`); + project.resolveSourceFileDependencies(); + return project; +} + +function countMissingCommentsApiForPlugin(doc: PluginApi) { + return ( + doc.client.reduce((sum, def) => { + return sum + countMissingCommentsForApi(def); + }, 0) + + doc.server.reduce((sum, def) => { + return sum + countMissingCommentsForApi(def); + }, 0) + + doc.common.reduce((sum, def) => { + return sum + countMissingCommentsForApi(def); + }, 0) + ); +} + +function countMissingCommentsForApi(doc: ApiDeclaration): number { + const missingCnt = doc.description && doc.description.length > 0 ? 0 : 1; + if (!doc.children) return missingCnt; + else + return ( + missingCnt + + doc.children.reduce((sum, child) => { + return sum + countMissingCommentsForApi(child); + }, 0) + ); +} + +function countApiForPlugin(doc: PluginApi) { + return ( + doc.client.reduce((sum, def) => { + return sum + countApi(def); + }, 0) + + doc.server.reduce((sum, def) => { + return sum + countApi(def); + }, 0) + + doc.common.reduce((sum, def) => { + return sum + countApi(def); + }, 0) + ); +} + +function countApi(doc: ApiDeclaration): number { + if (!doc.children) return 1; + else + return ( + 1 + + doc.children.reduce((sum, child) => { + return sum + countApi(child); + }, 0) + ); +} diff --git a/packages/kbn-docs-utils/src/api_docs/find_plugins.ts b/packages/kbn-docs-utils/src/api_docs/find_plugins.ts new file mode 100644 index 0000000000000..004124f13889d --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/find_plugins.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; + +import { getPluginSearchPaths } from '@kbn/config'; +import { simpleKibanaPlatformPluginDiscovery, REPO_ROOT } from '@kbn/dev-utils'; + +export function findPlugins() { + const pluginSearchPaths = getPluginSearchPaths({ + rootDir: REPO_ROOT, + oss: false, + examples: false, + }); + + return simpleKibanaPlatformPluginDiscovery(pluginSearchPaths, [ + // discover "core" as a plugin + Path.resolve(REPO_ROOT, 'src/core'), + ]); +} diff --git a/packages/kbn-docs-utils/src/api_docs/get_declaration_nodes_for_plugin.ts b/packages/kbn-docs-utils/src/api_docs/get_declaration_nodes_for_plugin.ts new file mode 100644 index 0000000000000..6676c5e753c9b --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/get_declaration_nodes_for_plugin.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { Project, SourceFile, Node } from 'ts-morph'; +import { ApiScope } from './types'; +import { isNamedNode, getSourceFileMatching } from './tsmorph_utils'; + +/** + * Determines which file in the project to grab nodes from, depending on the plugin and scope, then returns those nodes. + * + * @param project - TS project. + * @param plugin - The plugin we are interested in. + * @param scope - The "scope" of the API we want to extract: public, server or common. + * @param log - logging utility. + * + * @return Every publically exported Node from the given plugin and scope (public, server, common). + */ +export function getDeclarationNodesForPluginScope( + project: Project, + plugin: KibanaPlatformPlugin, + scope: ApiScope, + log: ToolingLog +): Node[] { + const path = Path.join(`${plugin.directory}`, scope.toString(), 'index.ts'); + const file = getSourceFileMatching(project, path); + + if (file) { + return getExportedFileDeclarations(file, log); + } else { + log.debug(`No file found: ${path}`); + return []; + } +} + +/** + * + * @param source the file we want to extract exported declaration nodes from. + * @param log + */ +function getExportedFileDeclarations(source: SourceFile, log: ToolingLog): Node[] { + const nodes: Node[] = []; + const exported = source.getExportedDeclarations(); + + // Filter out the exported declarations that exist only for the plugin system itself. + exported.forEach((val) => { + val.forEach((ed) => { + const name: string = isNamedNode(ed) ? ed.getName() : ''; + + // Every plugin will have an export called "plugin". Don't bother listing + // it, it's only for the plugin infrastructure. + // Config is also a common export on the server side that is just for the + // plugin infrastructure. + if (name === 'plugin' || name === 'config') { + return; + } + if (name && name !== '') { + nodes.push(ed); + } else { + log.warning(`API with missing name encountered.`); + } + }); + }); + + log.debug(`Collected ${nodes.length} exports from file ${source.getFilePath()}`); + return nodes; +} diff --git a/packages/kbn-docs-utils/src/api_docs/get_plugin_api.ts b/packages/kbn-docs-utils/src/api_docs/get_plugin_api.ts new file mode 100644 index 0000000000000..ede60e95ce2b9 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/get_plugin_api.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import { Node, Project, Type } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { ApiScope, Lifecycle } from './types'; +import { ApiDeclaration, PluginApi } from './types'; +import { buildApiDeclaration } from './build_api_declarations/build_api_declaration'; +import { getDeclarationNodesForPluginScope } from './get_declaration_nodes_for_plugin'; +import { getSourceFileMatching } from './tsmorph_utils'; + +/** + * Collects all the information neccessary to generate this plugins mdx api file(s). + */ +export function getPluginApi( + project: Project, + plugin: KibanaPlatformPlugin, + plugins: KibanaPlatformPlugin[], + log: ToolingLog +): PluginApi { + const client = getDeclarations(project, plugin, ApiScope.CLIENT, plugins, log); + const server = getDeclarations(project, plugin, ApiScope.SERVER, plugins, log); + const common = getDeclarations(project, plugin, ApiScope.COMMON, plugins, log); + return { + id: plugin.manifest.id, + client, + server, + common, + serviceFolders: plugin.manifest.serviceFolders, + }; +} + +/** + * + * @returns All exported ApiDeclarations for the given plugin and scope (client, server, common), broken into + * groups of typescript kinds (functions, classes, interfaces, etc). + */ +function getDeclarations( + project: Project, + plugin: KibanaPlatformPlugin, + scope: ApiScope, + plugins: KibanaPlatformPlugin[], + log: ToolingLog +): ApiDeclaration[] { + const nodes = getDeclarationNodesForPluginScope(project, plugin, scope, log); + + const contractTypes = getContractTypes(project, plugin, scope); + + const declarations = nodes.reduce((acc, node) => { + const apiDec = buildApiDeclaration(node, plugins, log, plugin.manifest.id, scope); + // Filter out apis with the @internal flag on them. + if (!apiDec.tags || apiDec.tags.indexOf('internal') < 0) { + // buildApiDeclaration doesn't set the lifecycle, so we set it here. + const lifecycle = getLifecycle(node, contractTypes); + acc.push({ + ...apiDec, + lifecycle, + initialIsOpen: lifecycle !== undefined, + }); + } + return acc; + }, []); + + // We have all the ApiDeclarations, now lets group them by typescript kinds. + return declarations; +} + +/** + * Checks if this node is one of the special start or setup contract interface types. We pull these + * to the top of the API docs. + * + * @param node ts-morph node + * @param contractTypeNames the start and setup contract interface names + * @returns Which, if any, lifecycle contract this node happens to represent. + */ +function getLifecycle( + node: Node, + contractTypeNames: { start?: Type; setup?: Type } +): Lifecycle | undefined { + // Note this logic is not tested if a plugin uses "as", + // like export { Setup as MyPluginSetup } from ..." + if (contractTypeNames.start && node.getType() === contractTypeNames.start) { + return Lifecycle.START; + } + + if (contractTypeNames.setup && node.getType() === contractTypeNames.setup) { + return Lifecycle.SETUP; + } +} + +/** + * + * @param project + * @param plugin the plugin we are interested in. + * @param scope Whether we are interested in the client or server plugin contracts. + * Common scope will never return anything. + * @returns the name of the two types used for Start and Setup contracts, if they + * exist and were exported from the plugin class. + */ +function getContractTypes( + project: Project, + plugin: KibanaPlatformPlugin, + scope: ApiScope +): { setup?: Type; start?: Type } { + const contractTypes: { setup?: Type; start?: Type } = {}; + const file = getSourceFileMatching( + project, + Path.join(`${plugin.directory}`, scope.toString(), 'plugin.ts') + ); + if (file) { + file.getClasses().forEach((c) => { + c.getImplements().forEach((i) => { + let index = 0; + i.getType() + .getTypeArguments() + .forEach((arg) => { + // Setup type comes first + if (index === 0) { + contractTypes.setup = arg; + } else if (index === 1) { + contractTypes.start = arg; + } + index++; + }); + }); + }); + } + return contractTypes; +} diff --git a/packages/kbn-docs-utils/src/api_docs/index.ts b/packages/kbn-docs-utils/src/api_docs/index.ts new file mode 100644 index 0000000000000..8dca507764a79 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './build_api_docs_cli'; diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/split_apis_by_folder.test.ts b/packages/kbn-docs-utils/src/api_docs/mdx/split_apis_by_folder.test.ts new file mode 100644 index 0000000000000..67e17c95d2298 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/mdx/split_apis_by_folder.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import { Project } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; + +import { PluginApi } from '../types'; +import { getKibanaPlatformPlugin } from '../tests/kibana_platform_plugin_mock'; +import { getPluginApi } from '../get_plugin_api'; +import { splitApisByFolder } from './write_plugin_split_by_folder'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +let doc: PluginApi; + +beforeAll(() => { + const tsConfigFilePath = Path.resolve(__dirname, '../tests/__fixtures__/src/tsconfig.json'); + const project = new Project({ + tsConfigFilePath, + }); + + expect(project.getSourceFiles().length).toBeGreaterThan(0); + + const pluginA = getKibanaPlatformPlugin('pluginA'); + pluginA.manifest.serviceFolders = ['foo']; + const plugins: KibanaPlatformPlugin[] = [pluginA]; + + doc = getPluginApi(project, plugins[0], plugins, log); +}); + +test('foo service has all exports', () => { + expect(doc?.client.length).toBe(33); + const split = splitApisByFolder(doc); + expect(split.length).toBe(2); + + const fooDoc = split.find((d) => d.id === 'pluginA.foo'); + const mainDoc = split.find((d) => d.id === 'pluginA'); + + expect(fooDoc?.common.length).toBe(1); + expect(fooDoc?.client.length).toBe(2); + expect(mainDoc?.client.length).toBe(31); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts new file mode 100644 index 0000000000000..b35515eb9d209 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog } from '@kbn/dev-utils'; +import fs from 'fs'; +import Path from 'path'; +import dedent from 'dedent'; +import { PluginApi, ScopeApi } from '../types'; +import { + countScopeApi, + getPluginApiDocId, + snakeToCamel, + camelToSnake, + groupPluginApi, +} from '../utils'; +import { writePluginDocSplitByFolder } from './write_plugin_split_by_folder'; + +/** + * Converts the plugin doc to mdx and writes it into the file system. If the plugin, + * has serviceFolders specified in it's kibana.json, multiple mdx files will be written. + * + * @param folder The location the mdx files will be written too. + * @param doc Contains the information of the plugin that will be written into mdx. + * @param log Used for logging debug and error information. + */ +export function writePluginDocs(folder: string, doc: PluginApi, log: ToolingLog): void { + if (doc.serviceFolders) { + log.debug(`Splitting plugin ${doc.id}`); + writePluginDocSplitByFolder(folder, doc, log); + } else { + writePluginDoc(folder, doc, log); + } +} + +function hasPublicApi(doc: PluginApi): boolean { + return doc.client.length > 0 || doc.server.length > 0 || doc.common.length > 0; +} + +/** + * Converts the plugin doc to mdx and writes it into the file system. Ignores + * the serviceFolders setting. Use {@link writePluginDocs} if you wish to split + * the plugin into potentially multiple mdx files. + * + * @param folder The location the mdx file will be written too. + * @param doc Contains the information of the plugin that will be written into mdx. + * @param log Used for logging debug and error information. + */ +export function writePluginDoc(folder: string, doc: PluginApi, log: ToolingLog): void { + if (!hasPublicApi(doc)) { + log.debug(`${doc.id} does not have a public api. Skipping.`); + return; + } + + log.debug(`Writing plugin file for ${doc.id}`); + + const fileName = getFileName(doc.id); + // Append "obj" to avoid special names in here. 'case' is one in particular that + // caused issues. + const json = getJsonName(fileName) + 'Obj'; + let mdx = + dedent(` +--- +id: ${getPluginApiDocId(doc.id, log)} +slug: /kibana-dev-docs/${doc.id}PluginApi +title: ${doc.id} +image: https://source.unsplash.com/400x175/?github +summary: API docs for the ${doc.id} plugin +date: 2020-11-16 +tags: ['contributor', 'dev', 'apidocs', 'kibana', '${doc.id}'] +--- + +import ${json} from './${fileName}.json'; + +`) + '\n\n'; + + const scopedDoc = { + ...doc, + client: groupPluginApi(doc.client), + common: groupPluginApi(doc.common), + server: groupPluginApi(doc.server), + }; + fs.writeFileSync(Path.resolve(folder, fileName + '.json'), JSON.stringify(scopedDoc)); + + mdx += scopApiToMdx(scopedDoc.client, 'Client', json, 'client'); + mdx += scopApiToMdx(scopedDoc.server, 'Server', json, 'server'); + mdx += scopApiToMdx(scopedDoc.common, 'Common', json, 'common'); + + fs.writeFileSync(Path.resolve(folder, fileName + '.mdx'), mdx); +} + +function getJsonName(name: string): string { + return snakeToCamel(getFileName(name)); +} + +function getFileName(name: string): string { + return camelToSnake(name.replace('.', '_')); +} + +function scopApiToMdx(scope: ScopeApi, title: string, json: string, scopeName: string): string { + let mdx = ''; + if (countScopeApi(scope) > 0) { + mdx += `## ${title}\n\n`; + + if (scope.setup) { + mdx += `### Setup\n`; + mdx += `\n`; + } + if (scope.start) { + mdx += `### Start\n`; + mdx += `\n`; + } + if (scope.objects.length > 0) { + mdx += `### Objects\n`; + mdx += `\n`; + } + if (scope.functions.length > 0) { + mdx += `### Functions\n`; + mdx += `\n`; + } + if (scope.classes.length > 0) { + mdx += `### Classes\n`; + mdx += `\n`; + } + if (scope.interfaces.length > 0) { + mdx += `### Interfaces\n`; + mdx += `\n`; + } + if (scope.enums.length > 0) { + mdx += `### Enums\n`; + mdx += `\n`; + } + if (scope.misc.length > 0) { + mdx += `### Consts, variables and types\n`; + mdx += `\n`; + } + } + return mdx; +} diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.test.ts b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.test.ts new file mode 100644 index 0000000000000..153b3299d8136 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Project } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; +import { splitApisByFolder } from './write_plugin_split_by_folder'; +import { getPluginApi } from '../get_plugin_api'; +import { getKibanaPlatformPlugin } from '../tests/kibana_platform_plugin_mock'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +it('splitApisByFolder test splitting plugin by service folder', () => { + const project = new Project({ useInMemoryFileSystem: true }); + project.createSourceFile( + 'src/plugins/example/public/index.ts', + ` +import { bar } from './a_service/foo/bar'; +import { Zed, zed } from './a_service/zed'; +import { util } from './utils'; + +export { bar, Zed, zed, mainFoo, util }; +` + ); + project.createSourceFile( + 'src/plugins/example/public/a_service/zed.ts', + `export const zed: string = 'hi'; +export interface Zed = { zed: string }` + ); + project.createSourceFile( + 'src/plugins/example/public/a_service/foo/bar.ts', + `export const bar: string = 'bar';` + ); + project.createSourceFile( + 'src/plugins/example/public/utils.ts', + `export const util: string = 'Util';` + ); + + const plugin = getKibanaPlatformPlugin('example', '/src/plugins/example'); + const plugins: KibanaPlatformPlugin[] = [ + { + ...plugin, + manifest: { + ...plugin.manifest, + serviceFolders: ['a_service'], + }, + }, + ]; + + const doc = getPluginApi(project, plugins[0], plugins, log); + const docs = splitApisByFolder(doc); + + // The api at the main level, and one on a service level. + expect(docs.length).toBe(2); + + const mainDoc = docs.find((d) => d.id === 'example'); + + expect(mainDoc).toBeDefined(); + + const serviceDoc = docs.find((d) => d.id === 'example.aService'); + + expect(serviceDoc).toBeDefined(); + + expect(serviceDoc?.client.length).toBe(3); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.ts b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.ts new file mode 100644 index 0000000000000..f5d547fc03520 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_split_by_folder.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog } from '@kbn/dev-utils'; +import { snakeToCamel } from '../utils'; +import { PluginApi, ApiDeclaration } from '../types'; +import { writePluginDoc } from './write_plugin_mdx_docs'; + +export function writePluginDocSplitByFolder(folder: string, doc: PluginApi, log: ToolingLog) { + const apisByFolder = splitApisByFolder(doc); + + log.debug(`Split ${doc.id} into ${apisByFolder.length} services`); + apisByFolder.forEach((docDef) => { + writePluginDoc(folder, docDef, log); + }); +} + +export function splitApisByFolder(pluginDoc: PluginApi): PluginApi[] { + const pluginDocDefsByFolder: { [key: string]: PluginApi } = {}; + const mainPluginDocDef = createServicePluginDocDef(pluginDoc); + + pluginDoc.client.forEach((dec: ApiDeclaration) => { + addSection(dec, 'client', mainPluginDocDef, pluginDocDefsByFolder, pluginDoc.serviceFolders!); + }); + pluginDoc.server.forEach((dec: ApiDeclaration) => { + addSection(dec, 'server', mainPluginDocDef, pluginDocDefsByFolder, pluginDoc.serviceFolders!); + }); + pluginDoc.common.forEach((dec: ApiDeclaration) => { + addSection(dec, 'common', mainPluginDocDef, pluginDocDefsByFolder, pluginDoc.serviceFolders!); + }); + + return [...Object.values(pluginDocDefsByFolder), mainPluginDocDef]; +} + +function addSection( + dec: ApiDeclaration, + scope: 'client' | 'server' | 'common', + mainPluginDocDef: PluginApi, + pluginServices: { [key: string]: PluginApi }, + serviceFolders: readonly string[] +) { + const scopeFolder = scope === 'client' ? 'public' : scope; + const matchGroup = dec.source.path.match(`.*?\/${scopeFolder}\/([^\/]*?)\/`); + const serviceFolderName = matchGroup ? matchGroup[1] : undefined; + + if (serviceFolderName && serviceFolders.find((f) => f === serviceFolderName)) { + const service = snakeToCamel(serviceFolderName); + if (!pluginServices[service]) { + pluginServices[service] = createServicePluginDocDef(mainPluginDocDef, service); + } + pluginServices[service][scope].push(dec); + } else { + mainPluginDocDef[scope].push(dec); + } +} + +function createServicePluginDocDef(pluginDoc: PluginApi, service?: string): PluginApi { + return { + id: service ? pluginDoc.id + '.' + service : pluginDoc.id, + client: [], + server: [], + common: [], + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts new file mode 100644 index 0000000000000..198856dbb10c4 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const commonFoo = 'COMMON VAR!'; diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts new file mode 100644 index 0000000000000..3fb7e375b2542 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { commonFoo } from './foo'; + +export interface ImACommonType { + goo: number; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/kibana.json b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/kibana.json new file mode 100644 index 0000000000000..84b46caa70802 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "pluginA", + "summary": "This an example plugin for testing the api documentation system", + "version": "kibana", + "serviceFolders": ["foo"] + } + \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts new file mode 100644 index 0000000000000..c68d146f71502 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable max-classes-per-file */ + +import { ImAType } from './types'; + +/** + * An interface with a generic. + */ +export interface WithGen { + t: T; +} + +export interface AnotherInterface { + t: T; +} + +export class ExampleClass implements AnotherInterface { + /** + * This should not be exposed in the docs! + */ + private privateVar: string; + + public component?: React.ComponentType; + + constructor(public t: T) { + this.privateVar = 'hi'; + } + + /** + * an arrow fn on a class. + * @param a im a string + */ + arrowFn = (a: ImAType): ImAType => a; + + /** + * A function on a class. + * @param a a param + */ + getVar(a: ImAType) { + return this.privateVar; + } +} + +export class CrazyClass

extends ExampleClass> {} + +/** + * This is an example interface so we can see how it appears inside the API + * documentation system. + */ +export interface ExampleInterface extends AnotherInterface { + /** + * This gets a promise that resolves to a string. + */ + getAPromiseThatResolvesToString: () => Promise; + + /** + * This function takes a generic. It was sometimes being tripped on + * and returned as an unknown type with no signature. + */ + aFnWithGen: (t: T) => void; + + /** + * These are not coming back properly. + */ + aFn(): void; +} + +/** + * An interface that has a react component. + */ +export interface IReturnAReactComponent { + component: React.ComponentType; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts new file mode 100644 index 0000000000000..52abd520f8259 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CrazyClass } from './classes'; +import { notAnArrowFn } from './fns'; +import { ImAType } from './types'; + +/** + * Some of the plugins wrap static exports in an object to create + * a namespace like this. + */ +export const aPretendNamespaceObj = { + /** + * The docs should show this inline comment. + */ + notAnArrowFn, + + /** + * Should this comment show up? + */ + aPropertyMisdirection: notAnArrowFn, + + /** + * I'm a property inline fun. + */ + aPropertyInlineFn: (a: ImAType): ImAType => { + return a; + }, + + /** + * The only way for this to have a comment is to grab this. + */ + aPropertyStr: 'Hi', + + /** + * Will this nested object have it's children extracted appropriately? + */ + nestedObj: { + foo: 'string', + }, +}; + +/** + * This is a complicated union type + */ +export const aUnionProperty: string | number | (() => string) | CrazyClass = '6'; + +/** + * This is an array of strings. The type is explicit. + */ +export const aStrArray: string[] = ['hi', 'bye']; + +/** + * This is an array of numbers. The type is implied. + */ +export const aNumArray = [1, 3, 4]; + +/** + * A string that says hi to you! + */ +export const aStr: string = 'hi'; + +/** + * It's a number. A special number. + */ +export const aNum = 10; + +/** + * I'm a type of string, but more specifically, a literal string type. + */ +export const literalString = 'HI'; diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts new file mode 100644 index 0000000000000..c341a80c0875d --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { TypeWithGeneric, ImAType } from './types'; + +/** + * This is a non arrow function. + * + * @param a The letter A + * @param b Feed me to the function + * @param c So many params + * @param d a great param + * @param e Another comment + * @returns something! + */ +export function notAnArrowFn( + a: string, + b: number | undefined, + c: TypeWithGeneric, + d: ImAType, + e?: string +): TypeWithGeneric { + return ['hi']; +} + +/** + * This is an arrow function. + * + * @param a The letter A + * @param b Feed me to the function + * @param c So many params + * @param d a great param + * @param e Another comment + * @returns something! + */ +export const arrowFn = ( + a: string, + b: number | undefined, + c: TypeWithGeneric, + d: ImAType, + e?: string +): TypeWithGeneric => { + return ['hi']; +}; + +/** + * Who would write such a complicated function?? Ewwww. + * + * According to https://jsdoc.app/tags-param.html#parameters-with-properties, + * this is how destructured arguements should be commented. + * + * @param obj A very crazy parameter that is destructured when passing in. + * @param objWithFn Im an object with a function. Destructed! + * @param objWithFn.fn A fn. + * @param objWithStr Im an object with a string. Destructed! + * @param objWithStr.str A str. + * + * @returns I have no idea. + * + */ +export const crazyFunction = ( + obj: { hi: string }, + { fn }: { fn: (foo: { param: string }) => number }, + { str }: { str: string } +) => () => () => fn({ param: str }); + +interface ImNotExported { + foo: string; +} + +export const fnWithNonExportedRef = (a: ImNotExported) => 'shi'; + +export type NotAnArrowFnType = typeof notAnArrowFn; diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts new file mode 100644 index 0000000000000..67db6d1a15db5 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const doTheFooFnThing = () => {}; + +export type FooType = () => 'foo'; + +export type ImNotExportedFromIndex = () => { bar: string }; diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts new file mode 100644 index 0000000000000..89bfb07e515ff --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginA, Setup, Start, SearchSpec } from './plugin'; +export { Setup, Start, SearchSpec }; + +export { doTheFooFnThing, FooType } from './foo'; + +export * from './fns'; +export * from './classes'; +export * from './const_vars'; +export * from './types'; + +export const imAnAny: any = 'hi'; +export const imAnUnknown: unknown = 'hi'; + +export function plugin() { + return new PluginA(); +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts new file mode 100644 index 0000000000000..839dc828c1886 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// The logic for grabbing Setup and Start types relies on implementing an +// interface with at least two type args. Since the test code isn't adding +// every import file, use this mock, otherwise it won't have the type and will +// fail. +interface PluginMock { + setup(): Sp; + start(): St; +} + +/** + * The SearchSpec interface contains settings for creating a new SearchService, like + * username and password. + */ +export interface SearchSpec { + /** + * Stores the username. Duh, + */ + username: string; + /** + * Stores the password. I hope it's encrypted! + */ + password: string; +} + +/** + * The type of search language. + */ +export enum SearchLanguage { + /** + * The SQL SearchLanguage type + */ + SQL, + /** + * The EQL SearchLanguage type. Support sequences. + */ + EQL, + /** + * The ES DSL SearchLanguage type. It's the default. + */ + ES_DSL, +} + +/** + * Access start functionality from your plugin's start function by adding the example + * plugin as a dependency. + * + * ```ts + * Class MyPlugin { + * start(core: CoreDependencies, { example }: PluginDependencies) { + * // Here you can access this functionality. + * example.getSearchLanguage(); + * } + * } + * ``` + */ +export interface Start { + /** + * @returns The currently selected {@link SearchLanguage} + */ + getSearchLanguage: () => SearchLanguage; +} + +/** + * Access setup functionality from your plugin's setup function by adding the example + * plugin as a dependency. + * + * ```ts + * Class MyPlugin { + * setup(core: CoreDependencies, { example }: PluginDependencies) { + * // Here you can access this functionality. + * example.getSearchService(); + * } + * } + * ``` + */ +export interface Setup { + /** + * A factory function that returns a new instance of Foo based + * on the spec. We aren't sure if this is a good function so it's marked + * beta. That should be clear in the docs because of the js doc tag. + * + * @param searchSpec Provide the settings neccessary to create a new Search Service + * + * @returns the id of the search service. + * + * @beta + */ + getSearchService: (searchSpec: SearchSpec) => string; + + /** + * This uses an inlined object type rather than referencing an exported type, which is discouraged. + * prefer the way {@link getSearchService} is typed. + * + * @param searchSpec Provide the settings neccessary to create a new Search Service + */ + getSearchService2: (searchSpec: { username: string; password: string }) => string; + + /** + * This function does the thing and it's so good at it! But we decided to deprecate it + * anyway. I hope that's clear to developers in the docs! + * + * @param thingOne Thing one comment + * @param thingTwo ThingTwo comment + * @param thingThree Thing three is an object with a nested var + * + * @deprecated + * + */ + doTheThing: (thingOne: number, thingTwo: string, thingThree: { nestedVar: number }) => void; + + /** + * Who would write such a complicated function?? Ew, how will the obj parameter appear in docs? + * + * @param obj A funky parameter. + * + * @returns It's hard to tell but I think this returns a function that returns an object with a + * property that is a function that returns a string. Whoa. + * + */ + fnWithInlineParams: (obj: { + fn: (foo: { param: string }) => number; + }) => () => { retFoo: () => string }; + + /** + * Hi, I'm a comment for an id string! + */ + id: string; +} + +/** + * This comment won't show up in the API docs. + */ +function getSearchService() { + return 'hi'; +} + +function fnWithInlineParams() { + return () => ({ + retFoo: () => 'hi', + }); +} + +/** + * The example search plugin is a fake plugin that is built only to test our api documentation system. + * + */ +export class PluginA implements PluginMock { + setup() { + return { + // Don't put comments here - they won't show up. What's here shouldn't matter because + // the API documentation system works off the type `Setup`. + doTheThing: () => {}, + fnWithInlineParams, + getSearchService, + getSearchService2: getSearchService, + registerSearch: () => {}, + id: '123', + }; + } + + start() { + return { getSearchLanguage: () => SearchLanguage.EQL }; + } +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts new file mode 100644 index 0000000000000..0f06f08018c22 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ImACommonType } from '../common'; +import { FooType, ImNotExportedFromIndex } from './foo'; + +/** + * How should a potentially undefined type show up. + */ +export type StringOrUndefinedType = string | undefined; + +export type TypeWithGeneric = T[]; + +export type ImAType = string | number | TypeWithGeneric | FooType | ImACommonType; + +/** + * This is a type that defines a function. + * + * @param t This is a generic T type. It can be anything. + */ +export type FnWithGeneric = (t: T) => TypeWithGeneric; + +/** + * Comments on enums. + */ +export enum DayOfWeek { + THURSDAY, + FRIDAY, // How about this comment, hmmm? + SATURDAY, +} + +/** + * Calling node.getSymbol().getDeclarations() will return > 1 declaration. + */ +export type MultipleDeclarationsType = TypeWithGeneric; + +export type IRefANotExportedType = ImNotExportedFromIndex | { zed: 'hi' }; +export interface ImAnObject { + foo: FnWithGeneric; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/tsconfig.json b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/tsconfig.json new file mode 100644 index 0000000000000..57353d8847ae1 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "incremental": false, + "strictNullChecks": true, + }, + "include": ["./**/*"] +} \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/api_doc_suite.test.ts b/packages/kbn-docs-utils/src/api_docs/tests/api_doc_suite.test.ts new file mode 100644 index 0000000000000..f32e58bdfc023 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/api_doc_suite.test.ts @@ -0,0 +1,397 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs'; +import Path from 'path'; + +import { Project } from 'ts-morph'; +import { ToolingLog, KibanaPlatformPlugin } from '@kbn/dev-utils'; + +import { writePluginDocs } from '../mdx/write_plugin_mdx_docs'; +import { ApiDeclaration, PluginApi, Reference, TextWithLinks, TypeKind } from '../types'; +import { getKibanaPlatformPlugin } from './kibana_platform_plugin_mock'; +import { getPluginApi } from '../get_plugin_api'; +import { groupPluginApi } from '../utils'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +let doc: PluginApi; +let mdxOutputFolder: string; + +function linkCount(signature: TextWithLinks): number { + return signature.reduce((cnt, next) => (typeof next === 'string' ? cnt : cnt + 1), 0); +} + +function fnIsCorrect(fn: ApiDeclaration | undefined) { + expect(fn).toBeDefined(); + expect(fn?.type).toBe(TypeKind.FunctionKind); + // The signature should contain a link to ExampleInterface param. + expect(fn?.signature).toBeDefined(); + expect(linkCount(fn!.signature!)).toBe(3); + + expect(fn?.children!.length).toBe(5); + expect(fn?.returnComment!.length).toBe(1); + + const p1 = fn?.children!.find((c) => c.label === 'a'); + expect(p1).toBeDefined(); + expect(p1!.type).toBe(TypeKind.StringKind); + expect(p1!.isRequired).toBe(true); + expect(p1!.signature?.length).toBe(1); + expect(linkCount(p1!.signature!)).toBe(0); + + const p2 = fn?.children!.find((c) => c.label === 'b'); + expect(p2).toBeDefined(); + expect(p2!.isRequired).toBe(false); + expect(p2!.type).toBe(TypeKind.NumberKind); + expect(p2!.signature?.length).toBe(1); + expect(linkCount(p2!.signature!)).toBe(0); + + const p3 = fn?.children!.find((c) => c.label === 'c'); + expect(p3).toBeDefined(); + expect(p3!.isRequired).toBe(true); + expect(p3!.type).toBe(TypeKind.ArrayKind); + expect(linkCount(p3!.signature!)).toBe(1); + + const p4 = fn?.children!.find((c) => c.label === 'd'); + expect(p4).toBeDefined(); + expect(p4!.isRequired).toBe(true); + expect(p4!.type).toBe(TypeKind.CompoundTypeKind); + expect(p4!.signature?.length).toBe(1); + expect(linkCount(p4!.signature!)).toBe(1); + + const p5 = fn?.children!.find((c) => c.label === 'e'); + expect(p5).toBeDefined(); + expect(p5!.isRequired).toBe(false); + expect(p5!.type).toBe(TypeKind.StringKind); + expect(p5!.signature?.length).toBe(1); + expect(linkCount(p5!.signature!)).toBe(0); +} + +beforeAll(() => { + const tsConfigFilePath = Path.resolve(__dirname, '__fixtures__/src/tsconfig.json'); + const project = new Project({ + tsConfigFilePath, + }); + + expect(project.getSourceFiles().length).toBeGreaterThan(0); + + const pluginA = getKibanaPlatformPlugin('pluginA'); + pluginA.manifest.serviceFolders = ['foo']; + const plugins: KibanaPlatformPlugin[] = [pluginA]; + + doc = getPluginApi(project, plugins[0], plugins, log); + + mdxOutputFolder = Path.resolve(__dirname, 'snapshots'); + writePluginDocs(mdxOutputFolder, doc, log); +}); + +it('Setup type is extracted', () => { + const grouped = groupPluginApi(doc.client); + expect(grouped.setup).toBeDefined(); +}); + +it('service mdx file was created', () => { + expect(fs.existsSync(Path.resolve(mdxOutputFolder, 'plugin_a_foo.mdx'))).toBe(true); +}); + +it('Setup type has comment', () => { + const grouped = groupPluginApi(doc.client); + expect(grouped.setup!.description).toBeDefined(); + expect(grouped.setup!.description).toMatchInlineSnapshot(` + Array [ + " + Access setup functionality from your plugin's setup function by adding the example + plugin as a dependency. + + \`\`\`ts + Class MyPlugin { + setup(core: CoreDependencies, { example }: PluginDependencies) { + // Here you can access this functionality. + example.getSearchService(); + } + } + \`\`\`", + ] + `); +}); + +it('const exported from common folder is correct', () => { + const fooConst = doc.common.find((c) => c.label === 'commonFoo'); + expect(fooConst).toBeDefined(); + + expect(fooConst!.source.path.replace(Path.sep, '/')).toContain( + 'src/plugin_a/common/foo/index.ts' + ); + expect(fooConst!.signature![0]).toBe('"COMMON VAR!"'); +}); + +describe('functions', () => { + it('function referencing missing type has link removed', () => { + const fn = doc.client.find((c) => c.label === 'fnWithNonExportedRef'); + expect(linkCount(fn?.signature!)).toBe(0); + }); + it('arrow function is exported correctly', () => { + const fn = doc.client.find((c) => c.label === 'arrowFn'); + // Using the same data as the not an arrow function so this is refactored. + fnIsCorrect(fn); + }); + + it('non arrow function is exported correctly', () => { + const fn = doc.client.find((c) => c.label === 'notAnArrowFn'); + // Using the same data as the arrow function so this is refactored. + fnIsCorrect(fn); + }); + + it('crazyFunction is typed correctly', () => { + const fn = doc.client!.find((c) => c.label === 'crazyFunction'); + + expect(fn).toBeDefined(); + + const obj = fn?.children?.find((c) => c.label === 'obj'); + expect(obj).toBeDefined(); + expect(obj!.children?.length).toBe(1); + + const hi = obj?.children?.find((c) => c.label === 'hi'); + expect(hi).toBeDefined(); + + const obj2 = fn?.children?.find((c) => c.label === '{ fn }'); + expect(obj2).toBeDefined(); + expect(obj2!.children?.length).toBe(1); + + const fn2 = obj2?.children?.find((c) => c.label === 'fn'); + expect(fn2).toBeDefined(); + expect(fn2?.type).toBe(TypeKind.FunctionKind); + }); +}); + +describe('objects', () => { + it('Object exported correctly', () => { + const obj = doc.client.find((c) => c.label === 'aPretendNamespaceObj'); + expect(obj).toBeDefined(); + + const fn = obj?.children?.find((c) => c.label === 'notAnArrowFn'); + expect(fn?.signature).toBeDefined(); + // Should just be typeof notAnArrowFn. + expect(linkCount(fn?.signature!)).toBe(1); + // Comment should be the inline one. + expect(fn?.description).toMatchInlineSnapshot(` + Array [ + "/** + * The docs should show this inline comment. + */", + ] + `); + + const fn2 = obj?.children?.find((c) => c.label === 'aPropertyInlineFn'); + expect(fn2?.signature).toBeDefined(); + // Should include 2 links to ImAType + expect(linkCount(fn2?.signature!)).toBe(2); + expect(fn2?.children).toBeDefined(); + + const nestedObj = obj?.children?.find((c) => c.label === 'nestedObj'); + // We aren't giving objects a signature. The children should contain all the information. + expect(nestedObj?.signature).toBeUndefined(); + expect(nestedObj?.children).toBeDefined(); + expect(nestedObj?.type).toBe(TypeKind.ObjectKind); + const foo = nestedObj?.children?.find((c) => c.label === 'foo'); + expect(foo?.type).toBe(TypeKind.StringKind); + }); +}); + +describe('Misc types', () => { + it('Explicitly typed array is returned with the correct type', () => { + const aStrArray = doc.client.find((c) => c.label === 'aStrArray'); + expect(aStrArray).toBeDefined(); + expect(aStrArray?.type).toBe(TypeKind.ArrayKind); + }); + + it('Implicitly typed array is returned with the correct type', () => { + const aNumArray = doc.client.find((c) => c.label === 'aNumArray'); + expect(aNumArray).toBeDefined(); + expect(aNumArray?.type).toBe(TypeKind.ArrayKind); + }); + + it('Explicitly typed string is returned with the correct type', () => { + const aStr = doc.client.find((c) => c.label === 'aStr'); + expect(aStr).toBeDefined(); + expect(aStr?.type).toBe(TypeKind.StringKind); + // signature would be the same as type, so it should be removed. + expect(aStr?.signature).toBeUndefined(); + }); + + it('Implicitly typed number is returned with the correct type', () => { + const aNum = doc.client.find((c) => c.label === 'aNum'); + expect(aNum).toBeDefined(); + expect(aNum?.type).toBe(TypeKind.NumberKind); + }); + + it('aUnionProperty is exported as a CompoundType with a call signature', () => { + const prop = doc.client.find((c) => c.label === 'aUnionProperty'); + expect(prop).toBeDefined(); + expect(prop?.type).toBe(TypeKind.CompoundTypeKind); + expect(linkCount(prop?.signature!)).toBe(1); + }); + + it('Function type is exported correctly', () => { + const fnType = doc.client.find((c) => c.label === 'FnWithGeneric'); + expect(fnType).toBeDefined(); + expect(fnType?.type).toBe(TypeKind.TypeKind); + expect(fnType?.signature!).toMatchInlineSnapshot(` + Array [ + "(t: T) => ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric", + }, + "", + ] + `); + expect(linkCount(fnType?.signature!)).toBe(1); + }); + + it('Union type is exported correctly', () => { + const type = doc.client.find((c) => c.label === 'ImAType'); + expect(type).toBeDefined(); + expect(type?.type).toBe(TypeKind.TypeKind); + expect(type?.signature).toBeDefined(); + expect(type?.signature!).toMatchInlineSnapshot(` + Array [ + "string | number | ", + Object { + "docId": "kibPluginAFooPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.FooType", + "text": "FooType", + }, + " | ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric", + }, + " | ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "common", + "section": "def-common.ImACommonType", + "text": "ImACommonType", + }, + ] + `); + + expect(linkCount(type?.signature!)).toBe(3); + expect((type!.signature![1] as Reference).docId).toBe('kibPluginAFooPluginApi'); + }); +}); + +describe('interfaces and classes', () => { + it('Basic interface exported correctly', () => { + const anInterface = doc.client.find((c) => c.label === 'IReturnAReactComponent'); + expect(anInterface).toBeDefined(); + + // Make sure it doesn't include a self referential link. + expect(anInterface?.signature).toBeUndefined(); + }); + + it('Interface which extends exported correctly', () => { + const exampleInterface = doc.client.find((c) => c.label === 'ExampleInterface'); + expect(exampleInterface).toBeDefined(); + expect(exampleInterface?.signature).toBeDefined(); + expect(exampleInterface?.type).toBe(TypeKind.InterfaceKind); + + expect(linkCount(exampleInterface?.signature!)).toBe(2); + + // TODO: uncomment if the bug is fixed. + // This is wrong, the link should be to `AnotherInterface` + // Another bug, this link is not being captured. + expect(exampleInterface?.signature).toMatchInlineSnapshot(` + Array [ + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.ExampleInterface", + "text": "ExampleInterface", + }, + " extends ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.AnotherInterface", + "text": "AnotherInterface", + }, + "", + ] + `); + }); + + it('Non arrow function on interface is exported as function type', () => { + const exampleInterface = doc.client.find((c) => c.label === 'ExampleInterface'); + expect(exampleInterface).toBeDefined(); + + const fn = exampleInterface!.children?.find((c) => c.label === 'aFn'); + expect(fn).toBeDefined(); + expect(fn?.type).toBe(TypeKind.FunctionKind); + }); + + it('Class exported correctly', () => { + const clss = doc.client.find((c) => c.label === 'CrazyClass'); + expect(clss).toBeDefined(); + expect(clss?.signature).toBeDefined(); + expect(clss?.type).toBe(TypeKind.ClassKind); + expect(clss?.signature).toMatchInlineSnapshot(` + Array [ + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.CrazyClass", + "text": "CrazyClass", + }, + "

extends ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.ExampleClass", + "text": "ExampleClass", + }, + "<", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": "def-public.WithGen", + "text": "WithGen", + }, + "

>", + ] + `); + expect(linkCount(clss?.signature!)).toBe(3); + }); + + it('Function with generic inside interface is exported with function type', () => { + const exampleInterface = doc.client.find((c) => c.label === 'ExampleInterface'); + expect(exampleInterface).toBeDefined(); + + const fnWithGeneric = exampleInterface?.children?.find((c) => c.label === 'aFnWithGen'); + expect(fnWithGeneric).toBeDefined(); + expect(fnWithGeneric?.type).toBe(TypeKind.FunctionKind); + }); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/tests/kibana_platform_plugin_mock.ts b/packages/kbn-docs-utils/src/api_docs/tests/kibana_platform_plugin_mock.ts new file mode 100644 index 0000000000000..9debca91b7ca8 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/kibana_platform_plugin_mock.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin } from '@kbn/dev-utils'; +import Path from 'path'; + +export function getKibanaPlatformPlugin(id: string, dir?: string): KibanaPlatformPlugin { + const directory = dir ?? Path.resolve(__dirname, '__fixtures__/src/plugin_a'); + return { + manifest: { + id, + ui: true, + server: true, + kibanaVersion: '1', + version: '1', + serviceFolders: [], + requiredPlugins: [], + requiredBundles: [], + optionalPlugins: [], + extraPublicDirs: [], + }, + directory, + manifestPath: Path.resolve(directory, 'kibana.json'), + }; +} diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json new file mode 100644 index 0000000000000..db25b8c4f021e --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json @@ -0,0 +1 @@ +{"id":"pluginA","client":{"classes":[{"id":"def-public.ExampleClass","type":"Class","label":"ExampleClass","description":[],"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleClass","text":"ExampleClass"}," implements ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"children":[{"id":"def-public.ExampleClass.component","type":"CompoundType","label":"component","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":30,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L30"},"signature":["React.ComponentClass<{}, any> | React.FunctionComponent<{}> | undefined"]},{"id":"def-public.ExampleClass.Unnamed","type":"Function","label":"Constructor","signature":["any"],"description":[],"children":[{"type":"Uncategorized","label":"t","isRequired":true,"signature":["T"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":32,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L32"}}],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":32,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L32"}},{"id":"def-public.ExampleClass.arrowFn","type":"Function","children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L40"}}],"signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["\nan arrow fn on a class."],"label":"arrowFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L40"},"returnComment":[]},{"id":"def-public.ExampleClass.getVar","type":"Function","label":"getVar","signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => string"],"description":["\nA function on a class."],"children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["a param"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46"}}],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":24,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L24"},"initialIsOpen":false},{"id":"def-public.CrazyClass","type":"Class","label":"CrazyClass","description":[],"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.CrazyClass","text":"CrazyClass"},"

extends ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleClass","text":"ExampleClass"},"<",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.WithGen","text":"WithGen"},"

>"],"children":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":51,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L51"},"initialIsOpen":false}],"functions":[{"id":"def-public.notAnArrowFn","type":"Function","label":"notAnArrowFn","signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["\nThis is a non arrow function.\n"],"children":[{"type":"string","label":"a","isRequired":true,"signature":["string"],"description":["The letter A"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":22,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L22"}},{"type":"number","label":"b","isRequired":false,"signature":["number | undefined"],"description":["Feed me to the function"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":23,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L23"}},{"type":"Array","label":"c","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["So many params"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":24,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L24"}},{"type":"CompoundType","label":"d","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["a great param"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":25,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L25"}},{"type":"string","label":"e","isRequired":false,"signature":["string | undefined"],"description":["Another comment"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L26"}}],"tags":[],"returnComment":["something!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L21"},"initialIsOpen":false},{"id":"def-public.arrowFn","type":"Function","children":[{"type":"string","label":"a","isRequired":true,"signature":["string"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":42,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L42"}},{"type":"number","label":"b","isRequired":false,"signature":["number | undefined"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L43"}},{"type":"Array","label":"c","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L44"}},{"type":"CompoundType","label":"d","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":45,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L45"}},{"type":"string","label":"e","isRequired":false,"signature":["string | undefined"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L46"}}],"signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e?: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["\nThis is an arrow function.\n"],"label":"arrowFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":41,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L41"},"returnComment":["something!"],"initialIsOpen":false},{"id":"def-public.crazyFunction","type":"Function","children":[{"id":"def-public.crazyFunction.obj","type":"Object","label":"obj","description":[],"children":[{"id":"def-public.crazyFunction.obj.hi","type":"string","label":"hi","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67"}},{"id":"def-public.crazyFunction.{-fn }","type":"Object","label":"{ fn }","description":[],"children":[{"id":"def-public.crazyFunction.{-fn }.fn","type":"Function","label":"fn","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68"},"signature":["(foo: { param: string; }) => number"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68"}},{"id":"def-public.crazyFunction.{-str }","type":"Object","label":"{ str }","description":[],"children":[{"id":"def-public.crazyFunction.{-str }.str","type":"string","label":"str","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":69,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":69,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69"}}],"signature":["(obj: { hi: string; }, { fn }: { fn: (foo: { param: string; }) => number; }, { str }: { str: string; }) => () => () => number"],"description":["\nWho would write such a complicated function?? Ewwww.\n\nAccording to https://jsdoc.app/tags-param.html#parameters-with-properties,\nthis is how destructured arguements should be commented.\n"],"label":"crazyFunction","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":66,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L66"},"returnComment":["I have no idea."],"initialIsOpen":false},{"id":"def-public.fnWithNonExportedRef","type":"Function","children":[{"type":"Object","label":"a","isRequired":true,"signature":["ImNotExported"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76"}}],"signature":["(a: ImNotExported) => string"],"description":[],"label":"fnWithNonExportedRef","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76"},"returnComment":[],"initialIsOpen":false}],"interfaces":[{"id":"def-public.SearchSpec","type":"Interface","label":"SearchSpec","description":["\nThe SearchSpec interface contains settings for creating a new SearchService, like\nusername and password."],"children":[{"id":"def-public.SearchSpec.username","type":"string","label":"username","description":["\nStores the username. Duh,"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L26"}},{"id":"def-public.SearchSpec.password","type":"string","label":"password","description":["\nStores the password. I hope it's encrypted!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":30,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L30"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":22,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L22"},"initialIsOpen":false},{"id":"def-public.WithGen","type":"Interface","label":"WithGen","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.WithGen","text":"WithGen"},""],"description":["\nAn interface with a generic."],"children":[{"id":"def-public.WithGen.t","type":"Uncategorized","label":"t","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L17"},"signature":["T"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":16,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L16"},"initialIsOpen":false},{"id":"def-public.AnotherInterface","type":"Interface","label":"AnotherInterface","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"description":[],"children":[{"id":"def-public.AnotherInterface.t","type":"Uncategorized","label":"t","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L21"},"signature":["T"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":20,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L20"},"initialIsOpen":false},{"id":"def-public.ExampleInterface","type":"Interface","label":"ExampleInterface","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleInterface","text":"ExampleInterface"}," extends ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"description":["\nThis is an example interface so we can see how it appears inside the API\ndocumentation system."],"children":[{"id":"def-public.ExampleInterface.getAPromiseThatResolvesToString","type":"Function","label":"getAPromiseThatResolvesToString","description":["\nThis gets a promise that resolves to a string."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":61,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L61"},"signature":["() => Promise"]},{"id":"def-public.ExampleInterface.aFnWithGen","type":"Function","label":"aFnWithGen","description":["\nThis function takes a generic. It was sometimes being tripped on\nand returned as an unknown type with no signature."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L67"},"signature":["(t: T) => void"]},{"id":"def-public.ExampleInterface.aFn","type":"Function","label":"aFn","signature":["() => void"],"description":["\nThese are not coming back properly."],"children":[],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":72,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L72"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":57,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L57"},"initialIsOpen":false},{"id":"def-public.IReturnAReactComponent","type":"Interface","label":"IReturnAReactComponent","description":["\nAn interface that has a react component."],"children":[{"id":"def-public.IReturnAReactComponent.component","type":"CompoundType","label":"component","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":79,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L79"},"signature":["React.ComponentType<{}>"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":78,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L78"},"initialIsOpen":false},{"id":"def-public.ImAnObject","type":"Interface","label":"ImAnObject","description":[],"children":[{"id":"def-public.ImAnObject.foo","type":"Function","label":"foo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L44"},"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.FnWithGeneric","text":"FnWithGeneric"}]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L43"},"initialIsOpen":false}],"enums":[{"id":"def-public.DayOfWeek","type":"Enum","label":"DayOfWeek","description":["\nComments on enums."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L31"},"initialIsOpen":false}],"misc":[{"id":"def-public.imAnAny","type":"Any","label":"imAnAny","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts","lineNumber":19,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L19"},"signature":["any"],"initialIsOpen":false},{"id":"def-public.imAnUnknown","type":"Unknown","label":"imAnUnknown","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts","lineNumber":20,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L20"},"signature":["unknown"],"initialIsOpen":false},{"id":"def-public.NotAnArrowFnType","type":"Type","label":"NotAnArrowFnType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":78,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L78"},"signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"initialIsOpen":false},{"id":"def-public.aUnionProperty","type":"CompoundType","label":"aUnionProperty","description":["\nThis is a complicated union type"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":51,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L51"},"signature":["string | number | (() => string) | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.CrazyClass","text":"CrazyClass"},""],"initialIsOpen":false},{"id":"def-public.aStrArray","type":"Array","label":"aStrArray","description":["\nThis is an array of strings. The type is explicit."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":56,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L56"},"signature":["string[]"],"initialIsOpen":false},{"id":"def-public.aNumArray","type":"Array","label":"aNumArray","description":["\nThis is an array of numbers. The type is implied."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":61,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L61"},"signature":["number[]"],"initialIsOpen":false},{"id":"def-public.aStr","type":"string","label":"aStr","description":["\nA string that says hi to you!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":66,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L66"},"initialIsOpen":false},{"id":"def-public.aNum","type":"number","label":"aNum","description":["\nIt's a number. A special number."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":71,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L71"},"signature":["10"],"initialIsOpen":false},{"id":"def-public.literalString","type":"string","label":"literalString","description":["\nI'm a type of string, but more specifically, a literal string type."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L76"},"signature":["\"HI\""],"initialIsOpen":false},{"id":"def-public.StringOrUndefinedType","type":"Type","label":"StringOrUndefinedType","description":["\nHow should a potentially undefined type show up."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":15,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L15"},"signature":["undefined | string"],"initialIsOpen":false},{"id":"def-public.TypeWithGeneric","type":"Type","label":"TypeWithGeneric","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L17"},"signature":["T[]"],"initialIsOpen":false},{"id":"def-public.ImAType","type":"Type","label":"ImAType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":19,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L19"},"signature":["string | number | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAFooPluginApi","section":"def-public.FooType","text":"FooType"}," | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"}," | ",{"pluginId":"pluginA","scope":"common","docId":"kibPluginAPluginApi","section":"def-common.ImACommonType","text":"ImACommonType"}],"initialIsOpen":false},{"id":"def-public.FnWithGeneric","type":"Type","label":"FnWithGeneric","description":["\nThis is a type that defines a function.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L26"},"signature":["(t: T) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"initialIsOpen":false},{"id":"def-public.MultipleDeclarationsType","type":"Type","label":"MultipleDeclarationsType","description":["\nCalling node.getSymbol().getDeclarations() will return > 1 declaration."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L40"},"signature":["(typeof DayOfWeek)[]"],"initialIsOpen":false},{"id":"def-public.IRefANotExportedType","type":"Type","label":"IRefANotExportedType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":42,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L42"},"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAFooPluginApi","section":"def-public.ImNotExportedFromIndex","text":"ImNotExportedFromIndex"}," | { zed: \"hi\"; }"],"initialIsOpen":false}],"objects":[{"id":"def-public.aPretendNamespaceObj","type":"Object","children":[{"id":"def-public.aPretendNamespaceObj.notAnArrowFn","type":"Function","label":"notAnArrowFn","description":["/**\n * The docs should show this inline comment.\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L21"},"signature":["typeof ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.notAnArrowFn","text":"notAnArrowFn"}]},{"id":"def-public.aPretendNamespaceObj.aPropertyMisdirection","type":"Function","label":"aPropertyMisdirection","description":["/**\n * Should this comment show up?\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L26"},"signature":["typeof ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.notAnArrowFn","text":"notAnArrowFn"}]},{"id":"def-public.aPretendNamespaceObj.aPropertyInlineFn","type":"Function","children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31"}}],"signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["/**\n * I'm a property inline fun.\n */"],"label":"aPropertyInlineFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31"},"returnComment":[]},{"id":"def-public.aPretendNamespaceObj.aPropertyStr","type":"string","label":"aPropertyStr","description":["/**\n * The only way for this to have a comment is to grab this.\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":38,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L38"}},{"id":"def-public.aPretendNamespaceObj.nestedObj","type":"Object","children":[{"id":"def-public.aPretendNamespaceObj.nestedObj.foo","type":"string","label":"foo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L44"}}],"description":["/**\n * Will this nested object have it's children extracted appropriately?\n */"],"label":"nestedObj","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L43"}}],"description":["\nSome of the plugins wrap static exports in an object to create\na namespace like this."],"label":"aPretendNamespaceObj","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L17"},"initialIsOpen":false}],"setup":{"id":"def-public.Setup","type":"Interface","label":"Setup","description":["\nAccess setup functionality from your plugin's setup function by adding the example\nplugin as a dependency.\n\n```ts\nClass MyPlugin {\n setup(core: CoreDependencies, { example }: PluginDependencies) {\n // Here you can access this functionality.\n example.getSearchService();\n }\n}\n```"],"children":[{"id":"def-public.Setup.getSearchService","type":"Function","label":"getSearchService","description":["\nA factory function that returns a new instance of Foo based\non the spec. We aren't sure if this is a good function so it's marked\nbeta. That should be clear in the docs because of the js doc tag.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":96,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L96"},"signature":["(searchSpec: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.SearchSpec","text":"SearchSpec"},") => string"]},{"id":"def-public.Setup.getSearchService2","type":"Function","label":"getSearchService2","description":["\nThis uses an inlined object type rather than referencing an exported type, which is discouraged.\nprefer the way {@link getSearchService} is typed.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":104,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L104"},"signature":["(searchSpec: { username: string; password: string; }) => string"]},{"id":"def-public.Setup.doTheThing","type":"Function","label":"doTheThing","description":["\nThis function does the thing and it's so good at it! But we decided to deprecate it\nanyway. I hope that's clear to developers in the docs!\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":117,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L117"},"signature":["(thingOne: number, thingTwo: string, thingThree: { nestedVar: number; }) => void"]},{"id":"def-public.Setup.fnWithInlineParams","type":"Function","label":"fnWithInlineParams","description":["\nWho would write such a complicated function?? Ew, how will the obj parameter appear in docs?\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":128,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L128"},"signature":["(obj: { fn: (foo: { param: string; }) => number; }) => () => { retFoo: () => string; }"]},{"id":"def-public.Setup.id","type":"string","label":"id","description":["\nHi, I'm a comment for an id string!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":135,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L135"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":84,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L84"},"lifecycle":"setup","initialIsOpen":true},"start":{"id":"def-public.Start","type":"Interface","label":"Start","description":["\nAccess start functionality from your plugin's start function by adding the example\nplugin as a dependency.\n\n```ts\nClass MyPlugin {\n start(core: CoreDependencies, { example }: PluginDependencies) {\n // Here you can access this functionality.\n example.getSearchLanguage();\n }\n}\n```"],"children":[{"id":"def-public.Start.getSearchLanguage","type":"Function","label":"getSearchLanguage","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L68"},"signature":["() => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.SearchLanguage","text":"SearchLanguage"}]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":64,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L64"},"lifecycle":"start","initialIsOpen":true}},"server":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[],"objects":[]},"common":{"classes":[],"functions":[],"interfaces":[{"id":"def-common.ImACommonType","type":"Interface","label":"ImACommonType","description":[],"children":[{"id":"def-common.ImACommonType.goo","type":"number","label":"goo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts","lineNumber":12,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L12"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts","lineNumber":11,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L11"},"initialIsOpen":false}],"enums":[],"misc":[],"objects":[]}} \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx new file mode 100644 index 0000000000000..615bf7cb2460d --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx @@ -0,0 +1,34 @@ +--- +id: kibPluginAPluginApi +slug: /kibana-dev-docs/pluginAPluginApi +title: pluginA +image: https://source.unsplash.com/400x175/?github +summary: API docs for the pluginA plugin +date: 2020-11-16 +tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA'] +--- + +import pluginAObj from './plugin_a.json'; + +## Client + +### Setup + +### Start + +### Objects + +### Functions + +### Classes + +### Interfaces + +### Enums + +### Consts, variables and types + +## Common + +### Interfaces + diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json new file mode 100644 index 0000000000000..8b5ec5f3da960 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json @@ -0,0 +1 @@ +{"id":"pluginA.foo","client":{"classes":[],"functions":[{"id":"def-public.doTheFooFnThing","type":"Function","children":[],"signature":["() => void"],"description":[],"label":"doTheFooFnThing","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts","lineNumber":9,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L9"},"returnComment":[],"initialIsOpen":false}],"interfaces":[],"enums":[],"misc":[{"id":"def-public.FooType","type":"Type","label":"FooType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts","lineNumber":11,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L11"},"signature":["() => \"foo\""],"initialIsOpen":false}],"objects":[]},"server":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[],"objects":[]},"common":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[{"id":"def-common.commonFoo","type":"string","label":"commonFoo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts","lineNumber":9,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts#L9"},"signature":["\"COMMON VAR!\""],"initialIsOpen":false}],"objects":[]}} \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx new file mode 100644 index 0000000000000..a4f05fdeb2076 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx @@ -0,0 +1,22 @@ +--- +id: kibPluginAFooPluginApi +slug: /kibana-dev-docs/pluginA.fooPluginApi +title: pluginA.foo +image: https://source.unsplash.com/400x175/?github +summary: API docs for the pluginA.foo plugin +date: 2020-11-16 +tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA.foo'] +--- + +import pluginAFooObj from './plugin_a_foo.json'; + +## Client + +### Functions + +### Consts, variables and types + +## Common + +### Consts, variables and types + diff --git a/packages/kbn-docs-utils/src/api_docs/tsmorph_utils.ts b/packages/kbn-docs-utils/src/api_docs/tsmorph_utils.ts new file mode 100644 index 0000000000000..f78c538019e2f --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/tsmorph_utils.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Node, SourceFile, Project } from 'ts-morph'; + +export interface NamedNode extends Node { + getName(): string; +} + +/** + * ts-morph has a Node.isNamedNode fn but it isn't returning true for all types + * that will have node.getName. + */ +export function isNamedNode(node: Node | NamedNode): node is NamedNode { + return (node as NamedNode).getName !== undefined; +} + +/** + * Helper function to find a source file at a given location. Used to extract + * index.ts files at a given scope. + * + * @param project The ts morph project which contains all the source files + * @param absolutePath The absolute path of the file we want to find + * @returns a source file that exists at the location of the relative path. + */ +export function getSourceFileMatching( + project: Project, + absolutePath: string +): SourceFile | undefined { + return project.getSourceFiles().find((file) => { + return file.getFilePath().startsWith(absolutePath); + }); +} diff --git a/packages/kbn-docs-utils/src/api_docs/types.ts b/packages/kbn-docs-utils/src/api_docs/types.ts new file mode 100644 index 0000000000000..c41cd42e6b424 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/types.ts @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface AnchorLink { + /** + * The plugin that contains the API being referenced. + */ + pluginName: string; + /** + * It's possible the client and the server both emit an API with + * the same name so we need scope in here to add uniqueness. + */ + scope: ApiScope; + /** + * The name of the api. + */ + apiName: string; +} + +/** + * The kinds of typescript types we want to show in the docs. `Unknown` is used if + * we aren't accounting for a particular type. See {@link getPropertyTypeKind} + */ +export enum TypeKind { + ClassKind = 'Class', + FunctionKind = 'Function', + ObjectKind = 'Object', + EnumKind = 'Enum', + InterfaceKind = 'Interface', + /** + * Maps to the typescript syntax kind `TypeReferences`. For example, + * export type FooFn = () => string will be a TypeKind, not a FunctionKind. + */ + TypeKind = 'Type', + /** + * Uncategorized is used if a type is encountered that isn't handled. + */ + Uncategorized = 'Uncategorized', + UnknownKind = 'Unknown', // Maps to the unknown typescript type + AnyKind = 'Any', // Maps to the any typescript type + StringKind = 'string', + NumberKind = 'number', + BooleanKind = 'boolean', + ArrayKind = 'Array', + /** + * This will cover things like string | number, or A & B, for lack of something better to put here. + */ + CompoundTypeKind = 'CompoundType', +} + +export interface ScopeApi { + setup?: ApiDeclaration; + start?: ApiDeclaration; + functions: ApiDeclaration[]; + objects: ApiDeclaration[]; + classes: ApiDeclaration[]; + interfaces: ApiDeclaration[]; + enums: ApiDeclaration[]; + misc: ApiDeclaration[]; +} + +export interface PluginApi { + id: string; + serviceFolders?: readonly string[]; + client: ApiDeclaration[]; + server: ApiDeclaration[]; + common: ApiDeclaration[]; +} + +/** + * This is used for displaying code or comments that may contain reference links. For example, a function + * signature that is `(a: import("src/plugin_b").Bar) => void` will be parsed into the following Array: + * + * ```ts + * [ + * '(a: ', + * { docId: 'pluginB', section: 'Bar', text: 'Bar' }, + * ') => void' + * ] + * ``` + * + * This is then used to render text with nested DocLinks so it looks like this: + * + * `(a: => ) => void` + */ +export type TextWithLinks = Array; + +/** + * The information neccessary to build a DocLink. + */ +export interface Reference { + pluginId: string; + scope: ApiScope; + docId: string; + section: string; + text: string; +} + +/** + * This type should eventually be replaced by something inside elastic-docs. + * It's what will be passed to an elastic-docs supplied component to make + * the API docs pretty. + */ +export interface ApiDeclaration { + /** + * Used for an anchor link to this Api. Can't use label as there can be two labels with the same + * text within the Client section and the Server section. + */ + id?: string; + + /** + * The name of the api. + */ + label: string; + + /** + * Should the list be expanded or collapsed initially? + */ + initialIsOpen?: boolean; + + /** + * The kind of type this API represents, e.g. string, number, Object, Interface, Class. + */ + type: TypeKind; + + /** + * Certain types have children. For instance classes have class members, functions will list + * their parameters here, classes will list their class members here, and objects and interfaces + * will list their properties. + */ + children?: ApiDeclaration[]; + + /** + * TODO + */ + isRequired?: boolean; + + /** + * Api node comment. + */ + description?: TextWithLinks; + + /** + * If the type is a function, it's signature should be displayed. Currently this overlaps with type + * sometimes, and will sometimes be left empty for large types (like classes and interfaces). + */ + signature?: TextWithLinks; + + /** + * Relevant for functions with @returns comments. + */ + returnComment?: TextWithLinks; + + /** + * Will contain the tags on a comment, like `beta` or `deprecated`. + * Won't include param or returns tags. + */ + tags?: string[]; + + /** + * Every plugn that exposes functionality from their setup and start contract + * should have a single exported type for each. These get pulled to the top because + * they are accessed differently than other exported functionality and types. + */ + lifecycle?: Lifecycle; + + /** + * Used to create links to github to view the code for this API. + */ + source: SourceLink; +} + +export interface SourceLink { + path: string; + lineNumber: number; + link: string; +} + +/** + * Developers will need to know whether these APIs are available on the client, server, or both. + */ +export enum ApiScope { + CLIENT = 'public', + SERVER = 'server', + COMMON = 'common', +} + +/** + * Start and Setup interfaces are special - their functionality is not imported statically but + * accessible via the dependent plugins start and setup functions. + */ +export enum Lifecycle { + START = 'start', + SETUP = 'setup', +} diff --git a/packages/kbn-docs-utils/src/api_docs/utils.test.ts b/packages/kbn-docs-utils/src/api_docs/utils.test.ts new file mode 100644 index 0000000000000..a506405616a47 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/utils.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import Path from 'path'; +import { Project } from 'ts-morph'; +import { findPlugins } from './find_plugins'; +import { getPluginApi } from './get_plugin_api'; +import { getKibanaPlatformPlugin } from './tests/kibana_platform_plugin_mock'; +import { PluginApi } from './types'; +import { getPluginForPath, getServiceForPath, removeBrokenLinks } from './utils'; + +const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, +}); + +it('test getPluginForPath', () => { + const plugins = findPlugins(); + const path = Path.resolve(__dirname, '../../../../src/plugins/embeddable/public/service/file.ts'); + expect(getPluginForPath(path, plugins)).toBeDefined(); +}); + +it('test getServiceForPath', () => { + expect(getServiceForPath('src/plugins/embed/public/service/file.ts', 'src/plugins/embed')).toBe( + 'service' + ); + expect( + getServiceForPath('src/plugins/embed/public/service/subfolder/file.ts', 'src/plugins/embed') + ).toBe('service'); + expect( + getServiceForPath('src/plugins/embed/public/file.ts', 'src/plugins/embed') + ).toBeUndefined(); + expect( + getServiceForPath('/src/plugins/embed/server/another_service/index', '/src/plugins/embed') + ).toBe('another_service'); + expect(getServiceForPath('src/plugins/embed/server/no_ending', 'src/plugins/embed')).toBe( + undefined + ); + expect( + getServiceForPath('src/plugins/embed/server/routes/public/foo/index.ts', 'src/plugins/embed') + ).toBe('routes'); + expect(getServiceForPath('src/plugins/embed/server/f.ts', 'src/plugins/embed')).toBeUndefined(); + + expect( + getServiceForPath( + '/var/lib/jenkins/workspace/elastic+kibana+pipeline-pull-request/kibana/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index', + '/var/lib/jenkins/workspace/elastic+kibana+pipeline-pull-request/kibana/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a' + ) + ).toBe('foo'); +}); + +it('test removeBrokenLinks', () => { + const tsConfigFilePath = Path.resolve(__dirname, 'tests/__fixtures__/src/tsconfig.json'); + const project = new Project({ + tsConfigFilePath, + }); + + expect(project.getSourceFiles().length).toBeGreaterThan(0); + + const pluginA = getKibanaPlatformPlugin('pluginA'); + pluginA.manifest.serviceFolders = ['foo']; + const plugins: KibanaPlatformPlugin[] = [pluginA]; + + const pluginApiMap: { [key: string]: PluginApi } = {}; + plugins.map((plugin) => { + pluginApiMap[plugin.manifest.id] = getPluginApi(project, plugin, plugins, log); + }); + + const missingApiItems: { [key: string]: string[] } = {}; + + plugins.forEach((plugin) => { + const id = plugin.manifest.id; + const pluginApi = pluginApiMap[id]; + removeBrokenLinks(pluginApi, missingApiItems, pluginApiMap); + }); + expect(missingApiItems.pluginA.indexOf('public.ImNotExportedFromIndex')).toBeGreaterThan(-1); +}); diff --git a/packages/kbn-docs-utils/src/api_docs/utils.ts b/packages/kbn-docs-utils/src/api_docs/utils.ts new file mode 100644 index 0000000000000..34162aa330911 --- /dev/null +++ b/packages/kbn-docs-utils/src/api_docs/utils.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; +import { + AnchorLink, + ApiDeclaration, + ScopeApi, + TypeKind, + Lifecycle, + PluginApi, + ApiScope, +} from './types'; + +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export const camelToSnake = (str: string): string => str.replace(/([A-Z])/g, '_$1').toLowerCase(); + +export const snakeToCamel = (str: string): string => + str.replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace('-', '').replace('_', '')); + +/** + * Returns the plugin that the file belongs to. + * @param path An absolute file path that can point to a file nested inside a plugin + * @param plugins A list of plugins to search through. + */ +export function getPluginForPath( + path: string, + plugins: KibanaPlatformPlugin[] +): KibanaPlatformPlugin | undefined { + return plugins.find((plugin) => path.startsWith(plugin.directory)); +} + +/** + * Groups ApiDeclarations by typescript kind - classes, functions, enums, etc, so they + * can be displayed separately in the mdx files. + */ +export function groupPluginApi(declarations: ApiDeclaration[]): ScopeApi { + const scope = createEmptyScope(); + + declarations.forEach((declaration) => { + addApiDeclarationToScope(declaration, scope); + }); + + return scope; +} + +function escapeRegExp(regexp: string) { + return regexp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +/** + * If the file is at the top level, returns undefined, otherwise returns the + * name of the first nested folder in the plugin. For example a path of + * 'src/plugins/data/public/search_services/file.ts' would return 'search_service' while + * 'src/plugin/data/server/file.ts' would return undefined. + * @param path + */ +export function getServiceForPath(path: string, pluginDirectory: string): string | undefined { + const dir = escapeRegExp(pluginDirectory); + const publicMatchGroups = path.match(`${dir}\/public\/([^\/]*)\/`); + const serverMatchGroups = path.match(`${dir}\/server\/([^\/]*)\/`); + const commonMatchGroups = path.match(`${dir}\/common\/([^\/]*)\/`); + + if (publicMatchGroups && publicMatchGroups.length > 1) { + return publicMatchGroups[1]; + } else if (serverMatchGroups && serverMatchGroups.length > 1) { + return serverMatchGroups[1]; + } else if (commonMatchGroups && commonMatchGroups.length > 1) { + return commonMatchGroups[1]; + } +} + +export function getPluginApiDocId( + id: string, + log: ToolingLog, + serviceInfo?: { + serviceFolders: readonly string[]; + apiPath: string; + directory: string; + } +) { + let service = ''; + const cleanName = id.replace('.', '_'); + if (serviceInfo) { + const serviceName = getServiceForPath(serviceInfo.apiPath, serviceInfo.directory); + log.debug( + `Service for path ${serviceInfo.apiPath} and ${serviceInfo.directory} is ${serviceName}` + ); + const serviceFolder = serviceInfo.serviceFolders?.find((f) => f === serviceName); + + if (serviceFolder) { + service = snakeToCamel(serviceFolder); + } + } + + return `kib${capitalize(snakeToCamel(cleanName)) + capitalize(service)}PluginApi`; +} + +export function getApiSectionId(link: AnchorLink) { + const id = `def-${link.scope}.${link.apiName}`.replace(' ', '-'); + return id; +} + +export function countScopeApi(api: ScopeApi): number { + return ( + (api.setup ? 1 : 0) + + (api.start ? 1 : 0) + + api.classes.length + + api.interfaces.length + + api.functions.length + + api.objects.length + + api.enums.length + + api.misc.length + ); +} + +export function createEmptyScope(): ScopeApi { + return { + classes: [], + functions: [], + interfaces: [], + enums: [], + misc: [], + objects: [], + }; +} + +/** + * Takes the ApiDeclaration and puts it in the appropriate section of the ScopeApi based + * on its TypeKind. + */ +export function addApiDeclarationToScope(declaration: ApiDeclaration, scope: ScopeApi): void { + if (declaration.lifecycle === Lifecycle.SETUP) { + scope.setup = declaration; + } else if (declaration.lifecycle === Lifecycle.START) { + scope.start = declaration; + } else { + switch (declaration.type) { + case TypeKind.ClassKind: + scope.classes.push(declaration); + break; + case TypeKind.InterfaceKind: + scope.interfaces.push(declaration); + break; + case TypeKind.EnumKind: + scope.enums.push(declaration); + break; + case TypeKind.FunctionKind: + scope.functions.push(declaration); + break; + case TypeKind.ObjectKind: + scope.objects.push(declaration); + break; + default: + scope.misc.push(declaration); + } + } +} + +export function removeBrokenLinks( + pluginApi: PluginApi, + missingApiItems: { [key: string]: string[] }, + pluginApiMap: { [key: string]: PluginApi } +) { + (['client', 'common', 'server'] as Array<'client' | 'server' | 'common'>).forEach((scope) => { + pluginApi[scope].forEach((api) => { + if (api.signature) { + api.signature = api.signature.map((sig) => { + if (typeof sig !== 'string') { + if (apiItemExists(sig.text, sig.scope, pluginApiMap[sig.pluginId]) === false) { + if (missingApiItems[sig.pluginId] === undefined) { + missingApiItems[sig.pluginId] = []; + } + missingApiItems[sig.pluginId].push(`${sig.scope}.${sig.text}`); + return sig.text; + } + } + return sig; + }); + } + }); + }); +} + +function apiItemExists(name: string, scope: ApiScope, pluginApi: PluginApi): boolean { + return ( + pluginApi[scopeAccessor(scope)].findIndex((dec: ApiDeclaration) => dec.label === name) >= 0 + ); +} + +function scopeAccessor(scope: ApiScope): 'server' | 'common' | 'client' { + switch (scope) { + case ApiScope.CLIENT: + return 'client'; + case ApiScope.SERVER: + return 'server'; + default: + return 'common'; + } +} diff --git a/packages/kbn-docs-utils/src/index.ts b/packages/kbn-docs-utils/src/index.ts new file mode 100644 index 0000000000000..24aef1bf891f6 --- /dev/null +++ b/packages/kbn-docs-utils/src/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './release_notes'; +export * from './api_docs'; diff --git a/packages/kbn-release-notes/src/cli.ts b/packages/kbn-docs-utils/src/release_notes/cli.ts similarity index 100% rename from packages/kbn-release-notes/src/cli.ts rename to packages/kbn-docs-utils/src/release_notes/cli.ts diff --git a/packages/kbn-release-notes/src/formats/asciidoc.ts b/packages/kbn-docs-utils/src/release_notes/formats/asciidoc.ts similarity index 100% rename from packages/kbn-release-notes/src/formats/asciidoc.ts rename to packages/kbn-docs-utils/src/release_notes/formats/asciidoc.ts diff --git a/packages/kbn-release-notes/src/formats/csv.ts b/packages/kbn-docs-utils/src/release_notes/formats/csv.ts similarity index 100% rename from packages/kbn-release-notes/src/formats/csv.ts rename to packages/kbn-docs-utils/src/release_notes/formats/csv.ts diff --git a/packages/kbn-release-notes/src/formats/format.ts b/packages/kbn-docs-utils/src/release_notes/formats/format.ts similarity index 100% rename from packages/kbn-release-notes/src/formats/format.ts rename to packages/kbn-docs-utils/src/release_notes/formats/format.ts diff --git a/packages/kbn-release-notes/src/formats/index.ts b/packages/kbn-docs-utils/src/release_notes/formats/index.ts similarity index 100% rename from packages/kbn-release-notes/src/formats/index.ts rename to packages/kbn-docs-utils/src/release_notes/formats/index.ts diff --git a/packages/kbn-release-notes/src/index.ts b/packages/kbn-docs-utils/src/release_notes/index.ts similarity index 100% rename from packages/kbn-release-notes/src/index.ts rename to packages/kbn-docs-utils/src/release_notes/index.ts diff --git a/packages/kbn-release-notes/src/lib/classify_pr.ts b/packages/kbn-docs-utils/src/release_notes/lib/classify_pr.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/classify_pr.ts rename to packages/kbn-docs-utils/src/release_notes/lib/classify_pr.ts diff --git a/packages/kbn-release-notes/src/lib/get_fix_references.test.ts b/packages/kbn-docs-utils/src/release_notes/lib/get_fix_references.test.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/get_fix_references.test.ts rename to packages/kbn-docs-utils/src/release_notes/lib/get_fix_references.test.ts diff --git a/packages/kbn-release-notes/src/lib/get_fix_references.ts b/packages/kbn-docs-utils/src/release_notes/lib/get_fix_references.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/get_fix_references.ts rename to packages/kbn-docs-utils/src/release_notes/lib/get_fix_references.ts diff --git a/packages/kbn-release-notes/src/lib/get_note_from_description.test.ts b/packages/kbn-docs-utils/src/release_notes/lib/get_note_from_description.test.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/get_note_from_description.test.ts rename to packages/kbn-docs-utils/src/release_notes/lib/get_note_from_description.test.ts diff --git a/packages/kbn-release-notes/src/lib/get_note_from_description.ts b/packages/kbn-docs-utils/src/release_notes/lib/get_note_from_description.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/get_note_from_description.ts rename to packages/kbn-docs-utils/src/release_notes/lib/get_note_from_description.ts diff --git a/packages/kbn-release-notes/src/lib/index.ts b/packages/kbn-docs-utils/src/release_notes/lib/index.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/index.ts rename to packages/kbn-docs-utils/src/release_notes/lib/index.ts diff --git a/packages/kbn-release-notes/src/lib/irrelevant_pr_summary.ts b/packages/kbn-docs-utils/src/release_notes/lib/irrelevant_pr_summary.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/irrelevant_pr_summary.ts rename to packages/kbn-docs-utils/src/release_notes/lib/irrelevant_pr_summary.ts diff --git a/packages/kbn-release-notes/src/lib/is_pr_relevant.ts b/packages/kbn-docs-utils/src/release_notes/lib/is_pr_relevant.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/is_pr_relevant.ts rename to packages/kbn-docs-utils/src/release_notes/lib/is_pr_relevant.ts diff --git a/packages/kbn-release-notes/src/lib/pr_api.ts b/packages/kbn-docs-utils/src/release_notes/lib/pr_api.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/pr_api.ts rename to packages/kbn-docs-utils/src/release_notes/lib/pr_api.ts diff --git a/packages/kbn-release-notes/src/lib/streams.ts b/packages/kbn-docs-utils/src/release_notes/lib/streams.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/streams.ts rename to packages/kbn-docs-utils/src/release_notes/lib/streams.ts diff --git a/packages/kbn-release-notes/src/lib/type_helpers.ts b/packages/kbn-docs-utils/src/release_notes/lib/type_helpers.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/type_helpers.ts rename to packages/kbn-docs-utils/src/release_notes/lib/type_helpers.ts diff --git a/packages/kbn-release-notes/src/lib/version.test.ts b/packages/kbn-docs-utils/src/release_notes/lib/version.test.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/version.test.ts rename to packages/kbn-docs-utils/src/release_notes/lib/version.test.ts diff --git a/packages/kbn-release-notes/src/lib/version.ts b/packages/kbn-docs-utils/src/release_notes/lib/version.ts similarity index 100% rename from packages/kbn-release-notes/src/lib/version.ts rename to packages/kbn-docs-utils/src/release_notes/lib/version.ts diff --git a/packages/kbn-release-notes/src/release_notes_config.ts b/packages/kbn-docs-utils/src/release_notes/release_notes_config.ts similarity index 100% rename from packages/kbn-release-notes/src/release_notes_config.ts rename to packages/kbn-docs-utils/src/release_notes/release_notes_config.ts diff --git a/packages/kbn-release-notes/tsconfig.json b/packages/kbn-docs-utils/tsconfig.json similarity index 81% rename from packages/kbn-release-notes/tsconfig.json rename to packages/kbn-docs-utils/tsconfig.json index 02209a29e5817..3c683f487b9f2 100644 --- a/packages/kbn-release-notes/tsconfig.json +++ b/packages/kbn-docs-utils/tsconfig.json @@ -8,5 +8,8 @@ }, "include": [ "src/**/*" + ], + "exclude": [ + "**/__fixtures__/**/*" ] } diff --git a/packages/kbn-logging/src/appenders.ts b/packages/kbn-logging/src/appenders.ts index 1b128c0f29201..48422db34b336 100644 --- a/packages/kbn-logging/src/appenders.ts +++ b/packages/kbn-logging/src/appenders.ts @@ -15,6 +15,24 @@ import { LogRecord } from './log_record'; */ export interface Appender { append(record: LogRecord): void; + /** + * Appenders can be "attached" to one another so that they are able to act + * as a sort of middleware by calling `append` on a different appender. + * + * As appenders cannot be attached to each other until they are configured, + * the `addAppender` method can be used to pass in a newly configured appender + * to attach. + */ + addAppender?(appenderRef: string, appender: Appender): void; + /** + * For appenders which implement `addAppender`, they should declare a list of + * `appenderRefs`, which specify the names of the appenders that their configuration + * depends on. + * + * Note that these are the appender key names that the user specifies in their + * config, _not_ the names of the appender types themselves. + */ + appenderRefs?: string[]; } /** diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 51e8a617e2446..f9ddbcac1e09d 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -94,7 +94,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _cli__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "run", function() { return _cli__WEBPACK_IMPORTED_MODULE_0__["run"]; }); -/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(519); +/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(518); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildBazelProductionProjects"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildNonBazelProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildNonBazelProductionProjects"]; }); @@ -108,7 +108,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(251); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "transformDependencies", function() { return _utils_package_json__WEBPACK_IMPORTED_MODULE_4__["transformDependencies"]; }); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(518); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(517); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getProjectPaths", function() { return _config__WEBPACK_IMPORTED_MODULE_5__["getProjectPaths"]; }); /* @@ -59481,7 +59481,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(is_path_inside__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(365); /* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(248); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(518); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(517); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -59638,9 +59638,9 @@ class Kibana { "use strict"; const minimatch = __webpack_require__(150); -const arrayUnion = __webpack_require__(515); -const arrayDiffer = __webpack_require__(516); -const arrify = __webpack_require__(517); +const arrayUnion = __webpack_require__(145); +const arrayDiffer = __webpack_require__(515); +const arrify = __webpack_require__(516); module.exports = (list, patterns, options = {}) => { list = arrify(list); @@ -59670,18 +59670,6 @@ module.exports = (list, patterns, options = {}) => { "use strict"; -module.exports = (...arguments_) => { - return [...new Set([].concat(...arguments_))]; -}; - - -/***/ }), -/* 516 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - const arrayDiffer = (array, ...values) => { const rest = new Set([].concat(...values)); return array.filter(element => !rest.has(element)); @@ -59691,7 +59679,7 @@ module.exports = arrayDiffer; /***/ }), -/* 517 */ +/* 516 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59721,7 +59709,7 @@ module.exports = arrify; /***/ }), -/* 518 */ +/* 517 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59780,12 +59768,12 @@ function getProjectPaths({ } /***/ }), -/* 519 */ +/* 518 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(520); +/* harmony import */ var _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(519); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return _build_bazel_production_projects__WEBPACK_IMPORTED_MODULE_0__["buildBazelProductionProjects"]; }); /* harmony import */ var _build_non_bazel_production_projects__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(745); @@ -59802,15 +59790,15 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 520 */ +/* 519 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildBazelProductionProjects", function() { return buildBazelProductionProjects; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(521); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(520); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(737); +/* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(736); /* harmony import */ var globby__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(globby__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); @@ -59910,7 +59898,7 @@ async function applyCorrectPermissions(project, kibanaRoot, buildRoot) { } /***/ }), -/* 521 */ +/* 520 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59918,14 +59906,14 @@ async function applyCorrectPermissions(project, kibanaRoot, buildRoot) { const EventEmitter = __webpack_require__(156); const path = __webpack_require__(4); const os = __webpack_require__(121); -const pMap = __webpack_require__(522); -const arrify = __webpack_require__(517); -const globby = __webpack_require__(525); -const hasGlob = __webpack_require__(721); -const cpFile = __webpack_require__(723); -const junk = __webpack_require__(733); -const pFilter = __webpack_require__(734); -const CpyError = __webpack_require__(736); +const pMap = __webpack_require__(521); +const arrify = __webpack_require__(516); +const globby = __webpack_require__(524); +const hasGlob = __webpack_require__(720); +const cpFile = __webpack_require__(722); +const junk = __webpack_require__(732); +const pFilter = __webpack_require__(733); +const CpyError = __webpack_require__(735); const defaultOptions = { ignoreJunk: true @@ -60076,12 +60064,12 @@ module.exports = (source, destination, { /***/ }), -/* 522 */ +/* 521 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const AggregateError = __webpack_require__(523); +const AggregateError = __webpack_require__(522); module.exports = async ( iterable, @@ -60164,12 +60152,12 @@ module.exports = async ( /***/ }), -/* 523 */ +/* 522 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const indentString = __webpack_require__(524); +const indentString = __webpack_require__(523); const cleanStack = __webpack_require__(244); const cleanInternalStack = stack => stack.replace(/\s+at .*aggregate-error\/index.js:\d+:\d+\)?/g, ''); @@ -60218,7 +60206,7 @@ module.exports = AggregateError; /***/ }), -/* 524 */ +/* 523 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60260,17 +60248,17 @@ module.exports = (string, count = 1, options) => { /***/ }), -/* 525 */ +/* 524 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const arrayUnion = __webpack_require__(526); +const arrayUnion = __webpack_require__(525); const glob = __webpack_require__(147); -const fastGlob = __webpack_require__(528); -const dirGlob = __webpack_require__(714); -const gitignore = __webpack_require__(717); +const fastGlob = __webpack_require__(527); +const dirGlob = __webpack_require__(713); +const gitignore = __webpack_require__(716); const DEFAULT_FILTER = () => false; @@ -60415,12 +60403,12 @@ module.exports.gitignore = gitignore; /***/ }), -/* 526 */ +/* 525 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var arrayUniq = __webpack_require__(527); +var arrayUniq = __webpack_require__(526); module.exports = function () { return arrayUniq([].concat.apply([], arguments)); @@ -60428,7 +60416,7 @@ module.exports = function () { /***/ }), -/* 527 */ +/* 526 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60497,10 +60485,10 @@ if ('Set' in global) { /***/ }), -/* 528 */ +/* 527 */ /***/ (function(module, exports, __webpack_require__) { -const pkg = __webpack_require__(529); +const pkg = __webpack_require__(528); module.exports = pkg.async; module.exports.default = pkg.async; @@ -60513,19 +60501,19 @@ module.exports.generateTasks = pkg.generateTasks; /***/ }), -/* 529 */ +/* 528 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var optionsManager = __webpack_require__(530); -var taskManager = __webpack_require__(531); -var reader_async_1 = __webpack_require__(685); -var reader_stream_1 = __webpack_require__(709); -var reader_sync_1 = __webpack_require__(710); -var arrayUtils = __webpack_require__(712); -var streamUtils = __webpack_require__(713); +var optionsManager = __webpack_require__(529); +var taskManager = __webpack_require__(530); +var reader_async_1 = __webpack_require__(684); +var reader_stream_1 = __webpack_require__(708); +var reader_sync_1 = __webpack_require__(709); +var arrayUtils = __webpack_require__(711); +var streamUtils = __webpack_require__(712); /** * Synchronous API. */ @@ -60591,7 +60579,7 @@ function isString(source) { /***/ }), -/* 530 */ +/* 529 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60629,13 +60617,13 @@ exports.prepare = prepare; /***/ }), -/* 531 */ +/* 530 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var patternUtils = __webpack_require__(532); +var patternUtils = __webpack_require__(531); /** * Generate tasks based on parent directory of each pattern. */ @@ -60726,16 +60714,16 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 532 */ +/* 531 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var globParent = __webpack_require__(533); +var globParent = __webpack_require__(532); var isGlob = __webpack_require__(172); -var micromatch = __webpack_require__(536); +var micromatch = __webpack_require__(535); var GLOBSTAR = '**'; /** * Return true for static pattern. @@ -60881,15 +60869,15 @@ exports.matchAny = matchAny; /***/ }), -/* 533 */ +/* 532 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var path = __webpack_require__(4); -var isglob = __webpack_require__(534); -var pathDirname = __webpack_require__(535); +var isglob = __webpack_require__(533); +var pathDirname = __webpack_require__(534); var isWin32 = __webpack_require__(121).platform() === 'win32'; module.exports = function globParent(str) { @@ -60912,7 +60900,7 @@ module.exports = function globParent(str) { /***/ }), -/* 534 */ +/* 533 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -60943,7 +60931,7 @@ module.exports = function isGlob(str) { /***/ }), -/* 535 */ +/* 534 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61093,7 +61081,7 @@ module.exports.win32 = win32; /***/ }), -/* 536 */ +/* 535 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61104,18 +61092,18 @@ module.exports.win32 = win32; */ var util = __webpack_require__(112); -var braces = __webpack_require__(537); -var toRegex = __webpack_require__(538); -var extend = __webpack_require__(651); +var braces = __webpack_require__(536); +var toRegex = __webpack_require__(537); +var extend = __webpack_require__(650); /** * Local dependencies */ -var compilers = __webpack_require__(653); -var parsers = __webpack_require__(680); -var cache = __webpack_require__(681); -var utils = __webpack_require__(682); +var compilers = __webpack_require__(652); +var parsers = __webpack_require__(679); +var cache = __webpack_require__(680); +var utils = __webpack_require__(681); var MAX_LENGTH = 1024 * 64; /** @@ -61977,7 +61965,7 @@ module.exports = micromatch; /***/ }), -/* 537 */ +/* 536 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61987,18 +61975,18 @@ module.exports = micromatch; * Module dependencies */ -var toRegex = __webpack_require__(538); -var unique = __webpack_require__(560); -var extend = __webpack_require__(561); +var toRegex = __webpack_require__(537); +var unique = __webpack_require__(559); +var extend = __webpack_require__(560); /** * Local dependencies */ -var compilers = __webpack_require__(563); -var parsers = __webpack_require__(576); -var Braces = __webpack_require__(580); -var utils = __webpack_require__(564); +var compilers = __webpack_require__(562); +var parsers = __webpack_require__(575); +var Braces = __webpack_require__(579); +var utils = __webpack_require__(563); var MAX_LENGTH = 1024 * 64; var cache = {}; @@ -62302,16 +62290,16 @@ module.exports = braces; /***/ }), -/* 538 */ +/* 537 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var safe = __webpack_require__(539); -var define = __webpack_require__(545); -var extend = __webpack_require__(553); -var not = __webpack_require__(557); +var safe = __webpack_require__(538); +var define = __webpack_require__(544); +var extend = __webpack_require__(552); +var not = __webpack_require__(556); var MAX_LENGTH = 1024 * 64; /** @@ -62464,10 +62452,10 @@ module.exports.makeRe = makeRe; /***/ }), -/* 539 */ +/* 538 */ /***/ (function(module, exports, __webpack_require__) { -var parse = __webpack_require__(540); +var parse = __webpack_require__(539); var types = parse.types; module.exports = function (re, opts) { @@ -62513,13 +62501,13 @@ function isRegExp (x) { /***/ }), -/* 540 */ +/* 539 */ /***/ (function(module, exports, __webpack_require__) { -var util = __webpack_require__(541); -var types = __webpack_require__(542); -var sets = __webpack_require__(543); -var positions = __webpack_require__(544); +var util = __webpack_require__(540); +var types = __webpack_require__(541); +var sets = __webpack_require__(542); +var positions = __webpack_require__(543); module.exports = function(regexpStr) { @@ -62801,11 +62789,11 @@ module.exports.types = types; /***/ }), -/* 541 */ +/* 540 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(542); -var sets = __webpack_require__(543); +var types = __webpack_require__(541); +var sets = __webpack_require__(542); // All of these are private and only used by randexp. @@ -62918,7 +62906,7 @@ exports.error = function(regexp, msg) { /***/ }), -/* 542 */ +/* 541 */ /***/ (function(module, exports) { module.exports = { @@ -62934,10 +62922,10 @@ module.exports = { /***/ }), -/* 543 */ +/* 542 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(542); +var types = __webpack_require__(541); var INTS = function() { return [{ type: types.RANGE , from: 48, to: 57 }]; @@ -63022,10 +63010,10 @@ exports.anyChar = function() { /***/ }), -/* 544 */ +/* 543 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(542); +var types = __webpack_require__(541); exports.wordBoundary = function() { return { type: types.POSITION, value: 'b' }; @@ -63045,7 +63033,7 @@ exports.end = function() { /***/ }), -/* 545 */ +/* 544 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63058,8 +63046,8 @@ exports.end = function() { -var isobject = __webpack_require__(546); -var isDescriptor = __webpack_require__(547); +var isobject = __webpack_require__(545); +var isDescriptor = __webpack_require__(546); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -63090,7 +63078,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 546 */ +/* 545 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63109,7 +63097,7 @@ module.exports = function isObject(val) { /***/ }), -/* 547 */ +/* 546 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63122,9 +63110,9 @@ module.exports = function isObject(val) { -var typeOf = __webpack_require__(548); -var isAccessor = __webpack_require__(549); -var isData = __webpack_require__(551); +var typeOf = __webpack_require__(547); +var isAccessor = __webpack_require__(548); +var isData = __webpack_require__(550); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -63138,7 +63126,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 548 */ +/* 547 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63273,7 +63261,7 @@ function isBuffer(val) { /***/ }), -/* 549 */ +/* 548 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63286,7 +63274,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(550); +var typeOf = __webpack_require__(549); // accessor descriptor properties var accessor = { @@ -63349,7 +63337,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 550 */ +/* 549 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63484,7 +63472,7 @@ function isBuffer(val) { /***/ }), -/* 551 */ +/* 550 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63497,7 +63485,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(552); +var typeOf = __webpack_require__(551); module.exports = function isDataDescriptor(obj, prop) { // data descriptor properties @@ -63540,7 +63528,7 @@ module.exports = function isDataDescriptor(obj, prop) { /***/ }), -/* 552 */ +/* 551 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63675,14 +63663,14 @@ function isBuffer(val) { /***/ }), -/* 553 */ +/* 552 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(554); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(553); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -63742,7 +63730,7 @@ function isEnum(obj, key) { /***/ }), -/* 554 */ +/* 553 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63755,7 +63743,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -63763,7 +63751,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 555 */ +/* 554 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63776,7 +63764,7 @@ module.exports = function isExtendable(val) { -var isObject = __webpack_require__(546); +var isObject = __webpack_require__(545); function isObjectObject(o) { return isObject(o) === true @@ -63807,7 +63795,7 @@ module.exports = function isPlainObject(o) { /***/ }), -/* 556 */ +/* 555 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63854,14 +63842,14 @@ module.exports = function(receiver, objects) { /***/ }), -/* 557 */ +/* 556 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(558); -var safe = __webpack_require__(539); +var extend = __webpack_require__(557); +var safe = __webpack_require__(538); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -63933,14 +63921,14 @@ module.exports = toRegex; /***/ }), -/* 558 */ +/* 557 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(559); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(558); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -64000,7 +63988,7 @@ function isEnum(obj, key) { /***/ }), -/* 559 */ +/* 558 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64013,7 +64001,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -64021,7 +64009,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 560 */ +/* 559 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64071,13 +64059,13 @@ module.exports.immutable = function uniqueImmutable(arr) { /***/ }), -/* 561 */ +/* 560 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(562); +var isObject = __webpack_require__(561); module.exports = function extend(o/*, objects*/) { if (!isObject(o)) { o = {}; } @@ -64111,7 +64099,7 @@ function hasOwn(obj, key) { /***/ }), -/* 562 */ +/* 561 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64131,13 +64119,13 @@ module.exports = function isExtendable(val) { /***/ }), -/* 563 */ +/* 562 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(564); +var utils = __webpack_require__(563); module.exports = function(braces, options) { braces.compiler @@ -64420,25 +64408,25 @@ function hasQueue(node) { /***/ }), -/* 564 */ +/* 563 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var splitString = __webpack_require__(565); +var splitString = __webpack_require__(564); var utils = module.exports; /** * Module dependencies */ -utils.extend = __webpack_require__(561); -utils.flatten = __webpack_require__(568); -utils.isObject = __webpack_require__(546); -utils.fillRange = __webpack_require__(569); -utils.repeat = __webpack_require__(575); -utils.unique = __webpack_require__(560); +utils.extend = __webpack_require__(560); +utils.flatten = __webpack_require__(567); +utils.isObject = __webpack_require__(545); +utils.fillRange = __webpack_require__(568); +utils.repeat = __webpack_require__(574); +utils.unique = __webpack_require__(559); utils.define = function(obj, key, val) { Object.defineProperty(obj, key, { @@ -64770,7 +64758,7 @@ utils.escapeRegex = function(str) { /***/ }), -/* 565 */ +/* 564 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64783,7 +64771,7 @@ utils.escapeRegex = function(str) { -var extend = __webpack_require__(566); +var extend = __webpack_require__(565); module.exports = function(str, options, fn) { if (typeof str !== 'string') { @@ -64948,14 +64936,14 @@ function keepEscaping(opts, str, idx) { /***/ }), -/* 566 */ +/* 565 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(567); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(566); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -65015,7 +65003,7 @@ function isEnum(obj, key) { /***/ }), -/* 567 */ +/* 566 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65028,7 +65016,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -65036,7 +65024,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 568 */ +/* 567 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65065,7 +65053,7 @@ function flat(arr, res) { /***/ }), -/* 569 */ +/* 568 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65079,10 +65067,10 @@ function flat(arr, res) { var util = __webpack_require__(112); -var isNumber = __webpack_require__(570); -var extend = __webpack_require__(561); -var repeat = __webpack_require__(573); -var toRegex = __webpack_require__(574); +var isNumber = __webpack_require__(569); +var extend = __webpack_require__(560); +var repeat = __webpack_require__(572); +var toRegex = __webpack_require__(573); /** * Return a range of numbers or letters. @@ -65280,7 +65268,7 @@ module.exports = fillRange; /***/ }), -/* 570 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65293,7 +65281,7 @@ module.exports = fillRange; -var typeOf = __webpack_require__(571); +var typeOf = __webpack_require__(570); module.exports = function isNumber(num) { var type = typeOf(num); @@ -65309,10 +65297,10 @@ module.exports = function isNumber(num) { /***/ }), -/* 571 */ +/* 570 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(572); +var isBuffer = __webpack_require__(571); var toString = Object.prototype.toString; /** @@ -65431,7 +65419,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 572 */ +/* 571 */ /***/ (function(module, exports) { /*! @@ -65458,7 +65446,7 @@ function isSlowBuffer (obj) { /***/ }), -/* 573 */ +/* 572 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65535,7 +65523,7 @@ function repeat(str, num) { /***/ }), -/* 574 */ +/* 573 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65548,8 +65536,8 @@ function repeat(str, num) { -var repeat = __webpack_require__(573); -var isNumber = __webpack_require__(570); +var repeat = __webpack_require__(572); +var isNumber = __webpack_require__(569); var cache = {}; function toRegexRange(min, max, options) { @@ -65836,7 +65824,7 @@ module.exports = toRegexRange; /***/ }), -/* 575 */ +/* 574 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65861,14 +65849,14 @@ module.exports = function repeat(ele, num) { /***/ }), -/* 576 */ +/* 575 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Node = __webpack_require__(577); -var utils = __webpack_require__(564); +var Node = __webpack_require__(576); +var utils = __webpack_require__(563); /** * Braces parsers @@ -66228,15 +66216,15 @@ function concatNodes(pos, node, parent, options) { /***/ }), -/* 577 */ +/* 576 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(546); -var define = __webpack_require__(578); -var utils = __webpack_require__(579); +var isObject = __webpack_require__(545); +var define = __webpack_require__(577); +var utils = __webpack_require__(578); var ownNames; /** @@ -66727,7 +66715,7 @@ exports = module.exports = Node; /***/ }), -/* 578 */ +/* 577 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -66740,7 +66728,7 @@ exports = module.exports = Node; -var isDescriptor = __webpack_require__(547); +var isDescriptor = __webpack_require__(546); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -66765,13 +66753,13 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 579 */ +/* 578 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(571); +var typeOf = __webpack_require__(570); var utils = module.exports; /** @@ -67791,17 +67779,17 @@ function assert(val, message) { /***/ }), -/* 580 */ +/* 579 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(561); -var Snapdragon = __webpack_require__(581); -var compilers = __webpack_require__(563); -var parsers = __webpack_require__(576); -var utils = __webpack_require__(564); +var extend = __webpack_require__(560); +var Snapdragon = __webpack_require__(580); +var compilers = __webpack_require__(562); +var parsers = __webpack_require__(575); +var utils = __webpack_require__(563); /** * Customize Snapdragon parser and renderer @@ -67902,17 +67890,17 @@ module.exports = Braces; /***/ }), -/* 581 */ +/* 580 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Base = __webpack_require__(582); -var define = __webpack_require__(609); -var Compiler = __webpack_require__(619); -var Parser = __webpack_require__(648); -var utils = __webpack_require__(628); +var Base = __webpack_require__(581); +var define = __webpack_require__(608); +var Compiler = __webpack_require__(618); +var Parser = __webpack_require__(647); +var utils = __webpack_require__(627); var regexCache = {}; var cache = {}; @@ -68083,20 +68071,20 @@ module.exports.Parser = Parser; /***/ }), -/* 582 */ +/* 581 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var define = __webpack_require__(583); -var CacheBase = __webpack_require__(584); -var Emitter = __webpack_require__(585); -var isObject = __webpack_require__(546); -var merge = __webpack_require__(603); -var pascal = __webpack_require__(606); -var cu = __webpack_require__(607); +var define = __webpack_require__(582); +var CacheBase = __webpack_require__(583); +var Emitter = __webpack_require__(584); +var isObject = __webpack_require__(545); +var merge = __webpack_require__(602); +var pascal = __webpack_require__(605); +var cu = __webpack_require__(606); /** * Optionally define a custom `cache` namespace to use. @@ -68525,7 +68513,7 @@ module.exports.namespace = namespace; /***/ }), -/* 583 */ +/* 582 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68538,7 +68526,7 @@ module.exports.namespace = namespace; -var isDescriptor = __webpack_require__(547); +var isDescriptor = __webpack_require__(546); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -68563,21 +68551,21 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 584 */ +/* 583 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(546); -var Emitter = __webpack_require__(585); -var visit = __webpack_require__(586); -var toPath = __webpack_require__(589); -var union = __webpack_require__(590); -var del = __webpack_require__(594); -var get = __webpack_require__(592); -var has = __webpack_require__(599); -var set = __webpack_require__(602); +var isObject = __webpack_require__(545); +var Emitter = __webpack_require__(584); +var visit = __webpack_require__(585); +var toPath = __webpack_require__(588); +var union = __webpack_require__(589); +var del = __webpack_require__(593); +var get = __webpack_require__(591); +var has = __webpack_require__(598); +var set = __webpack_require__(601); /** * Create a `Cache` constructor that when instantiated will @@ -68831,7 +68819,7 @@ module.exports.namespace = namespace; /***/ }), -/* 585 */ +/* 584 */ /***/ (function(module, exports, __webpack_require__) { @@ -69000,7 +68988,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 586 */ +/* 585 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69013,8 +69001,8 @@ Emitter.prototype.hasListeners = function(event){ -var visit = __webpack_require__(587); -var mapVisit = __webpack_require__(588); +var visit = __webpack_require__(586); +var mapVisit = __webpack_require__(587); module.exports = function(collection, method, val) { var result; @@ -69037,7 +69025,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 587 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69050,7 +69038,7 @@ module.exports = function(collection, method, val) { -var isObject = __webpack_require__(546); +var isObject = __webpack_require__(545); module.exports = function visit(thisArg, method, target, val) { if (!isObject(thisArg) && typeof thisArg !== 'function') { @@ -69077,14 +69065,14 @@ module.exports = function visit(thisArg, method, target, val) { /***/ }), -/* 588 */ +/* 587 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var visit = __webpack_require__(587); +var visit = __webpack_require__(586); /** * Map `visit` over an array of objects. @@ -69121,7 +69109,7 @@ function isObject(val) { /***/ }), -/* 589 */ +/* 588 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69134,7 +69122,7 @@ function isObject(val) { -var typeOf = __webpack_require__(571); +var typeOf = __webpack_require__(570); module.exports = function toPath(args) { if (typeOf(args) !== 'arguments') { @@ -69161,16 +69149,16 @@ function filter(arr) { /***/ }), -/* 590 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(562); -var union = __webpack_require__(591); -var get = __webpack_require__(592); -var set = __webpack_require__(593); +var isObject = __webpack_require__(561); +var union = __webpack_require__(590); +var get = __webpack_require__(591); +var set = __webpack_require__(592); module.exports = function unionValue(obj, prop, value) { if (!isObject(obj)) { @@ -69198,7 +69186,7 @@ function arrayify(val) { /***/ }), -/* 591 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69234,7 +69222,7 @@ module.exports = function union(init) { /***/ }), -/* 592 */ +/* 591 */ /***/ (function(module, exports) { /*! @@ -69290,7 +69278,7 @@ function toString(val) { /***/ }), -/* 593 */ +/* 592 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69303,10 +69291,10 @@ function toString(val) { -var split = __webpack_require__(565); -var extend = __webpack_require__(561); -var isPlainObject = __webpack_require__(555); -var isObject = __webpack_require__(562); +var split = __webpack_require__(564); +var extend = __webpack_require__(560); +var isPlainObject = __webpack_require__(554); +var isObject = __webpack_require__(561); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -69352,7 +69340,7 @@ function isValidKey(key) { /***/ }), -/* 594 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69365,8 +69353,8 @@ function isValidKey(key) { -var isObject = __webpack_require__(546); -var has = __webpack_require__(595); +var isObject = __webpack_require__(545); +var has = __webpack_require__(594); module.exports = function unset(obj, prop) { if (!isObject(obj)) { @@ -69391,7 +69379,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 595 */ +/* 594 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69404,9 +69392,9 @@ module.exports = function unset(obj, prop) { -var isObject = __webpack_require__(596); -var hasValues = __webpack_require__(598); -var get = __webpack_require__(592); +var isObject = __webpack_require__(595); +var hasValues = __webpack_require__(597); +var get = __webpack_require__(591); module.exports = function(obj, prop, noZero) { if (isObject(obj)) { @@ -69417,7 +69405,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 596 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69430,7 +69418,7 @@ module.exports = function(obj, prop, noZero) { -var isArray = __webpack_require__(597); +var isArray = __webpack_require__(596); module.exports = function isObject(val) { return val != null && typeof val === 'object' && isArray(val) === false; @@ -69438,7 +69426,7 @@ module.exports = function isObject(val) { /***/ }), -/* 597 */ +/* 596 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -69449,7 +69437,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 598 */ +/* 597 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69492,7 +69480,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 599 */ +/* 598 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69505,9 +69493,9 @@ module.exports = function hasValue(o, noZero) { -var isObject = __webpack_require__(546); -var hasValues = __webpack_require__(600); -var get = __webpack_require__(592); +var isObject = __webpack_require__(545); +var hasValues = __webpack_require__(599); +var get = __webpack_require__(591); module.exports = function(val, prop) { return hasValues(isObject(val) && prop ? get(val, prop) : val); @@ -69515,7 +69503,7 @@ module.exports = function(val, prop) { /***/ }), -/* 600 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69528,8 +69516,8 @@ module.exports = function(val, prop) { -var typeOf = __webpack_require__(601); -var isNumber = __webpack_require__(570); +var typeOf = __webpack_require__(600); +var isNumber = __webpack_require__(569); module.exports = function hasValue(val) { // is-number checks for NaN and other edge cases @@ -69582,10 +69570,10 @@ module.exports = function hasValue(val) { /***/ }), -/* 601 */ +/* 600 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(572); +var isBuffer = __webpack_require__(571); var toString = Object.prototype.toString; /** @@ -69707,7 +69695,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 602 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69720,10 +69708,10 @@ module.exports = function kindOf(val) { -var split = __webpack_require__(565); -var extend = __webpack_require__(561); -var isPlainObject = __webpack_require__(555); -var isObject = __webpack_require__(562); +var split = __webpack_require__(564); +var extend = __webpack_require__(560); +var isPlainObject = __webpack_require__(554); +var isObject = __webpack_require__(561); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -69769,14 +69757,14 @@ function isValidKey(key) { /***/ }), -/* 603 */ +/* 602 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(604); -var forIn = __webpack_require__(605); +var isExtendable = __webpack_require__(603); +var forIn = __webpack_require__(604); function mixinDeep(target, objects) { var len = arguments.length, i = 0; @@ -69840,7 +69828,7 @@ module.exports = mixinDeep; /***/ }), -/* 604 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69853,7 +69841,7 @@ module.exports = mixinDeep; -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -69861,7 +69849,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 605 */ +/* 604 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69884,7 +69872,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 606 */ +/* 605 */ /***/ (function(module, exports) { /*! @@ -69911,14 +69899,14 @@ module.exports = pascalcase; /***/ }), -/* 607 */ +/* 606 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var utils = __webpack_require__(608); +var utils = __webpack_require__(607); /** * Expose class utils @@ -70283,7 +70271,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 608 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70297,10 +70285,10 @@ var utils = {}; * Lazily required module dependencies */ -utils.union = __webpack_require__(591); -utils.define = __webpack_require__(609); -utils.isObj = __webpack_require__(546); -utils.staticExtend = __webpack_require__(616); +utils.union = __webpack_require__(590); +utils.define = __webpack_require__(608); +utils.isObj = __webpack_require__(545); +utils.staticExtend = __webpack_require__(615); /** @@ -70311,7 +70299,7 @@ module.exports = utils; /***/ }), -/* 609 */ +/* 608 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70324,7 +70312,7 @@ module.exports = utils; -var isDescriptor = __webpack_require__(610); +var isDescriptor = __webpack_require__(609); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -70349,7 +70337,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 610 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70362,9 +70350,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(611); -var isAccessor = __webpack_require__(612); -var isData = __webpack_require__(614); +var typeOf = __webpack_require__(610); +var isAccessor = __webpack_require__(611); +var isData = __webpack_require__(613); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -70378,7 +70366,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 611 */ +/* 610 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -70531,7 +70519,7 @@ function isBuffer(val) { /***/ }), -/* 612 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70544,7 +70532,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(613); +var typeOf = __webpack_require__(612); // accessor descriptor properties var accessor = { @@ -70607,10 +70595,10 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 613 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(572); +var isBuffer = __webpack_require__(571); var toString = Object.prototype.toString; /** @@ -70729,7 +70717,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 614 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70742,7 +70730,7 @@ module.exports = function kindOf(val) { -var typeOf = __webpack_require__(615); +var typeOf = __webpack_require__(614); // data descriptor properties var data = { @@ -70791,10 +70779,10 @@ module.exports = isDataDescriptor; /***/ }), -/* 615 */ +/* 614 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(572); +var isBuffer = __webpack_require__(571); var toString = Object.prototype.toString; /** @@ -70913,7 +70901,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 616 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70926,8 +70914,8 @@ module.exports = function kindOf(val) { -var copy = __webpack_require__(617); -var define = __webpack_require__(609); +var copy = __webpack_require__(616); +var define = __webpack_require__(608); var util = __webpack_require__(112); /** @@ -71010,15 +70998,15 @@ module.exports = extend; /***/ }), -/* 617 */ +/* 616 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(571); -var copyDescriptor = __webpack_require__(618); -var define = __webpack_require__(609); +var typeOf = __webpack_require__(570); +var copyDescriptor = __webpack_require__(617); +var define = __webpack_require__(608); /** * Copy static properties, prototype properties, and descriptors from one object to another. @@ -71191,7 +71179,7 @@ module.exports.has = has; /***/ }), -/* 618 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71279,16 +71267,16 @@ function isObject(val) { /***/ }), -/* 619 */ +/* 618 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(620); -var define = __webpack_require__(609); -var debug = __webpack_require__(622)('snapdragon:compiler'); -var utils = __webpack_require__(628); +var use = __webpack_require__(619); +var define = __webpack_require__(608); +var debug = __webpack_require__(621)('snapdragon:compiler'); +var utils = __webpack_require__(627); /** * Create a new `Compiler` with the given `options`. @@ -71442,7 +71430,7 @@ Compiler.prototype = { // source map support if (opts.sourcemap) { - var sourcemaps = __webpack_require__(647); + var sourcemaps = __webpack_require__(646); sourcemaps(this); this.mapVisit(this.ast.nodes); this.applySourceMaps(); @@ -71463,7 +71451,7 @@ module.exports = Compiler; /***/ }), -/* 620 */ +/* 619 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71476,7 +71464,7 @@ module.exports = Compiler; -var utils = __webpack_require__(621); +var utils = __webpack_require__(620); module.exports = function base(app, opts) { if (!utils.isObject(app) && typeof app !== 'function') { @@ -71591,7 +71579,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 621 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71605,8 +71593,8 @@ var utils = {}; * Lazily required module dependencies */ -utils.define = __webpack_require__(609); -utils.isObject = __webpack_require__(546); +utils.define = __webpack_require__(608); +utils.isObject = __webpack_require__(545); utils.isString = function(val) { @@ -71621,7 +71609,7 @@ module.exports = utils; /***/ }), -/* 622 */ +/* 621 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71630,14 +71618,14 @@ module.exports = utils; */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(623); + module.exports = __webpack_require__(622); } else { - module.exports = __webpack_require__(626); + module.exports = __webpack_require__(625); } /***/ }), -/* 623 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71646,7 +71634,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(624); +exports = module.exports = __webpack_require__(623); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -71828,7 +71816,7 @@ function localstorage() { /***/ }), -/* 624 */ +/* 623 */ /***/ (function(module, exports, __webpack_require__) { @@ -71844,7 +71832,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(625); +exports.humanize = __webpack_require__(624); /** * The currently active debug mode names, and names to skip. @@ -72036,7 +72024,7 @@ function coerce(val) { /***/ }), -/* 625 */ +/* 624 */ /***/ (function(module, exports) { /** @@ -72194,7 +72182,7 @@ function plural(ms, n, name) { /***/ }), -/* 626 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -72210,7 +72198,7 @@ var util = __webpack_require__(112); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(624); +exports = module.exports = __webpack_require__(623); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -72389,7 +72377,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(627); + var net = __webpack_require__(626); stream = new net.Socket({ fd: fd, readable: false, @@ -72448,13 +72436,13 @@ exports.enable(load()); /***/ }), -/* 627 */ +/* 626 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 628 */ +/* 627 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72464,9 +72452,9 @@ module.exports = require("net"); * Module dependencies */ -exports.extend = __webpack_require__(561); -exports.SourceMap = __webpack_require__(629); -exports.sourceMapResolve = __webpack_require__(640); +exports.extend = __webpack_require__(560); +exports.SourceMap = __webpack_require__(628); +exports.sourceMapResolve = __webpack_require__(639); /** * Convert backslash in the given string to forward slashes @@ -72509,7 +72497,7 @@ exports.last = function(arr, n) { /***/ }), -/* 629 */ +/* 628 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -72517,13 +72505,13 @@ exports.last = function(arr, n) { * Licensed under the New BSD license. See LICENSE.txt or: * http://opensource.org/licenses/BSD-3-Clause */ -exports.SourceMapGenerator = __webpack_require__(630).SourceMapGenerator; -exports.SourceMapConsumer = __webpack_require__(636).SourceMapConsumer; -exports.SourceNode = __webpack_require__(639).SourceNode; +exports.SourceMapGenerator = __webpack_require__(629).SourceMapGenerator; +exports.SourceMapConsumer = __webpack_require__(635).SourceMapConsumer; +exports.SourceNode = __webpack_require__(638).SourceNode; /***/ }), -/* 630 */ +/* 629 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72533,10 +72521,10 @@ exports.SourceNode = __webpack_require__(639).SourceNode; * http://opensource.org/licenses/BSD-3-Clause */ -var base64VLQ = __webpack_require__(631); -var util = __webpack_require__(633); -var ArraySet = __webpack_require__(634).ArraySet; -var MappingList = __webpack_require__(635).MappingList; +var base64VLQ = __webpack_require__(630); +var util = __webpack_require__(632); +var ArraySet = __webpack_require__(633).ArraySet; +var MappingList = __webpack_require__(634).MappingList; /** * An instance of the SourceMapGenerator represents a source map which is @@ -72945,7 +72933,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 631 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72985,7 +72973,7 @@ exports.SourceMapGenerator = SourceMapGenerator; * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var base64 = __webpack_require__(632); +var base64 = __webpack_require__(631); // A single base 64 digit can contain 6 bits of data. For the base 64 variable // length quantities we use in the source map spec, the first bit is the sign, @@ -73091,7 +73079,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 632 */ +/* 631 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73164,7 +73152,7 @@ exports.decode = function (charCode) { /***/ }), -/* 633 */ +/* 632 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73587,7 +73575,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 634 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73597,7 +73585,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(633); +var util = __webpack_require__(632); var has = Object.prototype.hasOwnProperty; var hasNativeMap = typeof Map !== "undefined"; @@ -73714,7 +73702,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 635 */ +/* 634 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73724,7 +73712,7 @@ exports.ArraySet = ArraySet; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(633); +var util = __webpack_require__(632); /** * Determine whether mappingB is after mappingA with respect to generated @@ -73799,7 +73787,7 @@ exports.MappingList = MappingList; /***/ }), -/* 636 */ +/* 635 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73809,11 +73797,11 @@ exports.MappingList = MappingList; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(633); -var binarySearch = __webpack_require__(637); -var ArraySet = __webpack_require__(634).ArraySet; -var base64VLQ = __webpack_require__(631); -var quickSort = __webpack_require__(638).quickSort; +var util = __webpack_require__(632); +var binarySearch = __webpack_require__(636); +var ArraySet = __webpack_require__(633).ArraySet; +var base64VLQ = __webpack_require__(630); +var quickSort = __webpack_require__(637).quickSort; function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; @@ -74887,7 +74875,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 637 */ +/* 636 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -75004,7 +74992,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 638 */ +/* 637 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -75124,7 +75112,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 639 */ +/* 638 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -75134,8 +75122,8 @@ exports.quickSort = function (ary, comparator) { * http://opensource.org/licenses/BSD-3-Clause */ -var SourceMapGenerator = __webpack_require__(630).SourceMapGenerator; -var util = __webpack_require__(633); +var SourceMapGenerator = __webpack_require__(629).SourceMapGenerator; +var util = __webpack_require__(632); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). @@ -75543,17 +75531,17 @@ exports.SourceNode = SourceNode; /***/ }), -/* 640 */ +/* 639 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014, 2015, 2016, 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var sourceMappingURL = __webpack_require__(641) -var resolveUrl = __webpack_require__(642) -var decodeUriComponent = __webpack_require__(643) -var urix = __webpack_require__(645) -var atob = __webpack_require__(646) +var sourceMappingURL = __webpack_require__(640) +var resolveUrl = __webpack_require__(641) +var decodeUriComponent = __webpack_require__(642) +var urix = __webpack_require__(644) +var atob = __webpack_require__(645) @@ -75851,7 +75839,7 @@ module.exports = { /***/ }), -/* 641 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;// Copyright 2014 Simon Lydell @@ -75914,7 +75902,7 @@ void (function(root, factory) { /***/ }), -/* 642 */ +/* 641 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75932,13 +75920,13 @@ module.exports = resolveUrl /***/ }), -/* 643 */ +/* 642 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var decodeUriComponent = __webpack_require__(644) +var decodeUriComponent = __webpack_require__(643) function customDecodeUriComponent(string) { // `decodeUriComponent` turns `+` into ` `, but that's not wanted. @@ -75949,7 +75937,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 644 */ +/* 643 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76050,7 +76038,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 645 */ +/* 644 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -76073,7 +76061,7 @@ module.exports = urix /***/ }), -/* 646 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76087,7 +76075,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 647 */ +/* 646 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76095,8 +76083,8 @@ module.exports = atob.atob = atob; var fs = __webpack_require__(134); var path = __webpack_require__(4); -var define = __webpack_require__(609); -var utils = __webpack_require__(628); +var define = __webpack_require__(608); +var utils = __webpack_require__(627); /** * Expose `mixin()`. @@ -76239,19 +76227,19 @@ exports.comment = function(node) { /***/ }), -/* 648 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(620); +var use = __webpack_require__(619); var util = __webpack_require__(112); -var Cache = __webpack_require__(649); -var define = __webpack_require__(609); -var debug = __webpack_require__(622)('snapdragon:parser'); -var Position = __webpack_require__(650); -var utils = __webpack_require__(628); +var Cache = __webpack_require__(648); +var define = __webpack_require__(608); +var debug = __webpack_require__(621)('snapdragon:parser'); +var Position = __webpack_require__(649); +var utils = __webpack_require__(627); /** * Create a new `Parser` with the given `input` and `options`. @@ -76779,7 +76767,7 @@ module.exports = Parser; /***/ }), -/* 649 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76886,13 +76874,13 @@ MapCache.prototype.del = function mapDelete(key) { /***/ }), -/* 650 */ +/* 649 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(609); +var define = __webpack_require__(608); /** * Store position for a node @@ -76907,14 +76895,14 @@ module.exports = function Position(start, parser) { /***/ }), -/* 651 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(652); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(651); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -76974,7 +76962,7 @@ function isEnum(obj, key) { /***/ }), -/* 652 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76987,7 +76975,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -76995,14 +76983,14 @@ module.exports = function isExtendable(val) { /***/ }), -/* 653 */ +/* 652 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var nanomatch = __webpack_require__(654); -var extglob = __webpack_require__(669); +var nanomatch = __webpack_require__(653); +var extglob = __webpack_require__(668); module.exports = function(snapdragon) { var compilers = snapdragon.compiler.compilers; @@ -77079,7 +77067,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 654 */ +/* 653 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77090,17 +77078,17 @@ function escapeExtglobs(compiler) { */ var util = __webpack_require__(112); -var toRegex = __webpack_require__(538); -var extend = __webpack_require__(655); +var toRegex = __webpack_require__(537); +var extend = __webpack_require__(654); /** * Local dependencies */ -var compilers = __webpack_require__(657); -var parsers = __webpack_require__(658); -var cache = __webpack_require__(661); -var utils = __webpack_require__(663); +var compilers = __webpack_require__(656); +var parsers = __webpack_require__(657); +var cache = __webpack_require__(660); +var utils = __webpack_require__(662); var MAX_LENGTH = 1024 * 64; /** @@ -77924,14 +77912,14 @@ module.exports = nanomatch; /***/ }), -/* 655 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(656); -var assignSymbols = __webpack_require__(556); +var isExtendable = __webpack_require__(655); +var assignSymbols = __webpack_require__(555); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -77991,7 +77979,7 @@ function isEnum(obj, key) { /***/ }), -/* 656 */ +/* 655 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78004,7 +77992,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(555); +var isPlainObject = __webpack_require__(554); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -78012,7 +78000,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 657 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78358,15 +78346,15 @@ module.exports = function(nanomatch, options) { /***/ }), -/* 658 */ +/* 657 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regexNot = __webpack_require__(557); -var toRegex = __webpack_require__(538); -var isOdd = __webpack_require__(659); +var regexNot = __webpack_require__(556); +var toRegex = __webpack_require__(537); +var isOdd = __webpack_require__(658); /** * Characters to use in negation regex (we want to "not" match @@ -78752,7 +78740,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 659 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78765,7 +78753,7 @@ module.exports.not = NOT_REGEX; -var isNumber = __webpack_require__(660); +var isNumber = __webpack_require__(659); module.exports = function isOdd(i) { if (!isNumber(i)) { @@ -78779,7 +78767,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 660 */ +/* 659 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78807,14 +78795,14 @@ module.exports = function isNumber(num) { /***/ }), -/* 661 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(662))(); +module.exports = new (__webpack_require__(661))(); /***/ }), -/* 662 */ +/* 661 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78827,7 +78815,7 @@ module.exports = new (__webpack_require__(662))(); -var MapCache = __webpack_require__(649); +var MapCache = __webpack_require__(648); /** * Create a new `FragmentCache` with an optional object to use for `caches`. @@ -78949,7 +78937,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 663 */ +/* 662 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78962,14 +78950,14 @@ var path = __webpack_require__(4); * Module dependencies */ -var isWindows = __webpack_require__(664)(); -var Snapdragon = __webpack_require__(581); -utils.define = __webpack_require__(665); -utils.diff = __webpack_require__(666); -utils.extend = __webpack_require__(655); -utils.pick = __webpack_require__(667); -utils.typeOf = __webpack_require__(668); -utils.unique = __webpack_require__(560); +var isWindows = __webpack_require__(663)(); +var Snapdragon = __webpack_require__(580); +utils.define = __webpack_require__(664); +utils.diff = __webpack_require__(665); +utils.extend = __webpack_require__(654); +utils.pick = __webpack_require__(666); +utils.typeOf = __webpack_require__(667); +utils.unique = __webpack_require__(559); /** * Returns true if the given value is effectively an empty string @@ -79335,7 +79323,7 @@ utils.unixify = function(options) { /***/ }), -/* 664 */ +/* 663 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*! @@ -79363,7 +79351,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 665 */ +/* 664 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79376,8 +79364,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ -var isobject = __webpack_require__(546); -var isDescriptor = __webpack_require__(547); +var isobject = __webpack_require__(545); +var isDescriptor = __webpack_require__(546); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -79408,7 +79396,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 666 */ +/* 665 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79462,7 +79450,7 @@ function diffArray(one, two) { /***/ }), -/* 667 */ +/* 666 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79475,7 +79463,7 @@ function diffArray(one, two) { -var isObject = __webpack_require__(546); +var isObject = __webpack_require__(545); module.exports = function pick(obj, keys) { if (!isObject(obj) && typeof obj !== 'function') { @@ -79504,7 +79492,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 668 */ +/* 667 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -79639,7 +79627,7 @@ function isBuffer(val) { /***/ }), -/* 669 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79649,18 +79637,18 @@ function isBuffer(val) { * Module dependencies */ -var extend = __webpack_require__(561); -var unique = __webpack_require__(560); -var toRegex = __webpack_require__(538); +var extend = __webpack_require__(560); +var unique = __webpack_require__(559); +var toRegex = __webpack_require__(537); /** * Local dependencies */ -var compilers = __webpack_require__(670); -var parsers = __webpack_require__(676); -var Extglob = __webpack_require__(679); -var utils = __webpack_require__(678); +var compilers = __webpack_require__(669); +var parsers = __webpack_require__(675); +var Extglob = __webpack_require__(678); +var utils = __webpack_require__(677); var MAX_LENGTH = 1024 * 64; /** @@ -79977,13 +79965,13 @@ module.exports = extglob; /***/ }), -/* 670 */ +/* 669 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(671); +var brackets = __webpack_require__(670); /** * Extglob compilers @@ -80153,7 +80141,7 @@ module.exports = function(extglob) { /***/ }), -/* 671 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80163,17 +80151,17 @@ module.exports = function(extglob) { * Local dependencies */ -var compilers = __webpack_require__(672); -var parsers = __webpack_require__(674); +var compilers = __webpack_require__(671); +var parsers = __webpack_require__(673); /** * Module dependencies */ -var debug = __webpack_require__(622)('expand-brackets'); -var extend = __webpack_require__(561); -var Snapdragon = __webpack_require__(581); -var toRegex = __webpack_require__(538); +var debug = __webpack_require__(621)('expand-brackets'); +var extend = __webpack_require__(560); +var Snapdragon = __webpack_require__(580); +var toRegex = __webpack_require__(537); /** * Parses the given POSIX character class `pattern` and returns a @@ -80371,13 +80359,13 @@ module.exports = brackets; /***/ }), -/* 672 */ +/* 671 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var posix = __webpack_require__(673); +var posix = __webpack_require__(672); module.exports = function(brackets) { brackets.compiler @@ -80465,7 +80453,7 @@ module.exports = function(brackets) { /***/ }), -/* 673 */ +/* 672 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80494,14 +80482,14 @@ module.exports = { /***/ }), -/* 674 */ +/* 673 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(675); -var define = __webpack_require__(609); +var utils = __webpack_require__(674); +var define = __webpack_require__(608); /** * Text regex @@ -80720,14 +80708,14 @@ module.exports.TEXT_REGEX = TEXT_REGEX; /***/ }), -/* 675 */ +/* 674 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var toRegex = __webpack_require__(538); -var regexNot = __webpack_require__(557); +var toRegex = __webpack_require__(537); +var regexNot = __webpack_require__(556); var cached; /** @@ -80761,15 +80749,15 @@ exports.createRegex = function(pattern, include) { /***/ }), -/* 676 */ +/* 675 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(671); -var define = __webpack_require__(677); -var utils = __webpack_require__(678); +var brackets = __webpack_require__(670); +var define = __webpack_require__(676); +var utils = __webpack_require__(677); /** * Characters to use in text regex (we want to "not" match @@ -80924,7 +80912,7 @@ module.exports = parsers; /***/ }), -/* 677 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80937,7 +80925,7 @@ module.exports = parsers; -var isDescriptor = __webpack_require__(547); +var isDescriptor = __webpack_require__(546); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -80962,14 +80950,14 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 678 */ +/* 677 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regex = __webpack_require__(557); -var Cache = __webpack_require__(662); +var regex = __webpack_require__(556); +var Cache = __webpack_require__(661); /** * Utils @@ -81038,7 +81026,7 @@ utils.createRegex = function(str) { /***/ }), -/* 679 */ +/* 678 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81048,16 +81036,16 @@ utils.createRegex = function(str) { * Module dependencies */ -var Snapdragon = __webpack_require__(581); -var define = __webpack_require__(677); -var extend = __webpack_require__(561); +var Snapdragon = __webpack_require__(580); +var define = __webpack_require__(676); +var extend = __webpack_require__(560); /** * Local dependencies */ -var compilers = __webpack_require__(670); -var parsers = __webpack_require__(676); +var compilers = __webpack_require__(669); +var parsers = __webpack_require__(675); /** * Customize Snapdragon parser and renderer @@ -81123,16 +81111,16 @@ module.exports = Extglob; /***/ }), -/* 680 */ +/* 679 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extglob = __webpack_require__(669); -var nanomatch = __webpack_require__(654); -var regexNot = __webpack_require__(557); -var toRegex = __webpack_require__(538); +var extglob = __webpack_require__(668); +var nanomatch = __webpack_require__(653); +var regexNot = __webpack_require__(556); +var toRegex = __webpack_require__(537); var not; /** @@ -81213,14 +81201,14 @@ function textRegex(pattern) { /***/ }), -/* 681 */ +/* 680 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(662))(); +module.exports = new (__webpack_require__(661))(); /***/ }), -/* 682 */ +/* 681 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81233,13 +81221,13 @@ var path = __webpack_require__(4); * Module dependencies */ -var Snapdragon = __webpack_require__(581); -utils.define = __webpack_require__(683); -utils.diff = __webpack_require__(666); -utils.extend = __webpack_require__(651); -utils.pick = __webpack_require__(667); -utils.typeOf = __webpack_require__(684); -utils.unique = __webpack_require__(560); +var Snapdragon = __webpack_require__(580); +utils.define = __webpack_require__(682); +utils.diff = __webpack_require__(665); +utils.extend = __webpack_require__(650); +utils.pick = __webpack_require__(666); +utils.typeOf = __webpack_require__(683); +utils.unique = __webpack_require__(559); /** * Returns true if the platform is windows, or `path.sep` is `\\`. @@ -81536,7 +81524,7 @@ utils.unixify = function(options) { /***/ }), -/* 683 */ +/* 682 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81549,8 +81537,8 @@ utils.unixify = function(options) { -var isobject = __webpack_require__(546); -var isDescriptor = __webpack_require__(547); +var isobject = __webpack_require__(545); +var isDescriptor = __webpack_require__(546); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -81581,7 +81569,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 684 */ +/* 683 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -81716,7 +81704,7 @@ function isBuffer(val) { /***/ }), -/* 685 */ +/* 684 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81735,9 +81723,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(686); -var reader_1 = __webpack_require__(699); -var fs_stream_1 = __webpack_require__(703); +var readdir = __webpack_require__(685); +var reader_1 = __webpack_require__(698); +var fs_stream_1 = __webpack_require__(702); var ReaderAsync = /** @class */ (function (_super) { __extends(ReaderAsync, _super); function ReaderAsync() { @@ -81798,15 +81786,15 @@ exports.default = ReaderAsync; /***/ }), -/* 686 */ +/* 685 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readdirSync = __webpack_require__(687); -const readdirAsync = __webpack_require__(695); -const readdirStream = __webpack_require__(698); +const readdirSync = __webpack_require__(686); +const readdirAsync = __webpack_require__(694); +const readdirStream = __webpack_require__(697); module.exports = exports = readdirAsyncPath; exports.readdir = exports.readdirAsync = exports.async = readdirAsyncPath; @@ -81890,7 +81878,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 687 */ +/* 686 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81898,11 +81886,11 @@ function readdirStreamStat (dir, options) { module.exports = readdirSync; -const DirectoryReader = __webpack_require__(688); +const DirectoryReader = __webpack_require__(687); let syncFacade = { - fs: __webpack_require__(693), - forEach: __webpack_require__(694), + fs: __webpack_require__(692), + forEach: __webpack_require__(693), sync: true }; @@ -81931,7 +81919,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 688 */ +/* 687 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81940,9 +81928,9 @@ function readdirSync (dir, options, internalOptions) { const Readable = __webpack_require__(138).Readable; const EventEmitter = __webpack_require__(156).EventEmitter; const path = __webpack_require__(4); -const normalizeOptions = __webpack_require__(689); -const stat = __webpack_require__(691); -const call = __webpack_require__(692); +const normalizeOptions = __webpack_require__(688); +const stat = __webpack_require__(690); +const call = __webpack_require__(691); /** * Asynchronously reads the contents of a directory and streams the results @@ -82318,14 +82306,14 @@ module.exports = DirectoryReader; /***/ }), -/* 689 */ +/* 688 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const globToRegExp = __webpack_require__(690); +const globToRegExp = __webpack_require__(689); module.exports = normalizeOptions; @@ -82502,7 +82490,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 690 */ +/* 689 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -82639,13 +82627,13 @@ module.exports = function (glob, opts) { /***/ }), -/* 691 */ +/* 690 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const call = __webpack_require__(692); +const call = __webpack_require__(691); module.exports = stat; @@ -82720,7 +82708,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 692 */ +/* 691 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82781,14 +82769,14 @@ function callOnce (fn) { /***/ }), -/* 693 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const call = __webpack_require__(692); +const call = __webpack_require__(691); /** * A facade around {@link fs.readdirSync} that allows it to be called @@ -82852,7 +82840,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 694 */ +/* 693 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82881,7 +82869,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 695 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82889,12 +82877,12 @@ function syncForEach (array, iterator, done) { module.exports = readdirAsync; -const maybe = __webpack_require__(696); -const DirectoryReader = __webpack_require__(688); +const maybe = __webpack_require__(695); +const DirectoryReader = __webpack_require__(687); let asyncFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(697), + forEach: __webpack_require__(696), async: true }; @@ -82936,7 +82924,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 696 */ +/* 695 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82963,7 +82951,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 697 */ +/* 696 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82999,7 +82987,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 698 */ +/* 697 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83007,11 +82995,11 @@ function asyncForEach (array, iterator, done) { module.exports = readdirStream; -const DirectoryReader = __webpack_require__(688); +const DirectoryReader = __webpack_require__(687); let streamFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(697), + forEach: __webpack_require__(696), async: true }; @@ -83031,16 +83019,16 @@ function readdirStream (dir, options, internalOptions) { /***/ }), -/* 699 */ +/* 698 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var deep_1 = __webpack_require__(700); -var entry_1 = __webpack_require__(702); -var pathUtil = __webpack_require__(701); +var deep_1 = __webpack_require__(699); +var entry_1 = __webpack_require__(701); +var pathUtil = __webpack_require__(700); var Reader = /** @class */ (function () { function Reader(options) { this.options = options; @@ -83106,14 +83094,14 @@ exports.default = Reader; /***/ }), -/* 700 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(701); -var patternUtils = __webpack_require__(532); +var pathUtils = __webpack_require__(700); +var patternUtils = __webpack_require__(531); var DeepFilter = /** @class */ (function () { function DeepFilter(options, micromatchOptions) { this.options = options; @@ -83196,7 +83184,7 @@ exports.default = DeepFilter; /***/ }), -/* 701 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83227,14 +83215,14 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 702 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(701); -var patternUtils = __webpack_require__(532); +var pathUtils = __webpack_require__(700); +var patternUtils = __webpack_require__(531); var EntryFilter = /** @class */ (function () { function EntryFilter(options, micromatchOptions) { this.options = options; @@ -83319,7 +83307,7 @@ exports.default = EntryFilter; /***/ }), -/* 703 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83339,8 +83327,8 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var fsStat = __webpack_require__(704); -var fs_1 = __webpack_require__(708); +var fsStat = __webpack_require__(703); +var fs_1 = __webpack_require__(707); var FileSystemStream = /** @class */ (function (_super) { __extends(FileSystemStream, _super); function FileSystemStream() { @@ -83390,14 +83378,14 @@ exports.default = FileSystemStream; /***/ }), -/* 704 */ +/* 703 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const optionsManager = __webpack_require__(705); -const statProvider = __webpack_require__(707); +const optionsManager = __webpack_require__(704); +const statProvider = __webpack_require__(706); /** * Asynchronous API. */ @@ -83428,13 +83416,13 @@ exports.statSync = statSync; /***/ }), -/* 705 */ +/* 704 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsAdapter = __webpack_require__(706); +const fsAdapter = __webpack_require__(705); function prepare(opts) { const options = Object.assign({ fs: fsAdapter.getFileSystemAdapter(opts ? opts.fs : undefined), @@ -83447,7 +83435,7 @@ exports.prepare = prepare; /***/ }), -/* 706 */ +/* 705 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83470,7 +83458,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 707 */ +/* 706 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83522,7 +83510,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 708 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83553,7 +83541,7 @@ exports.default = FileSystem; /***/ }), -/* 709 */ +/* 708 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83573,9 +83561,9 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var readdir = __webpack_require__(686); -var reader_1 = __webpack_require__(699); -var fs_stream_1 = __webpack_require__(703); +var readdir = __webpack_require__(685); +var reader_1 = __webpack_require__(698); +var fs_stream_1 = __webpack_require__(702); var TransformStream = /** @class */ (function (_super) { __extends(TransformStream, _super); function TransformStream(reader) { @@ -83643,7 +83631,7 @@ exports.default = ReaderStream; /***/ }), -/* 710 */ +/* 709 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83662,9 +83650,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(686); -var reader_1 = __webpack_require__(699); -var fs_sync_1 = __webpack_require__(711); +var readdir = __webpack_require__(685); +var reader_1 = __webpack_require__(698); +var fs_sync_1 = __webpack_require__(710); var ReaderSync = /** @class */ (function (_super) { __extends(ReaderSync, _super); function ReaderSync() { @@ -83724,7 +83712,7 @@ exports.default = ReaderSync; /***/ }), -/* 711 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83743,8 +83731,8 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var fsStat = __webpack_require__(704); -var fs_1 = __webpack_require__(708); +var fsStat = __webpack_require__(703); +var fs_1 = __webpack_require__(707); var FileSystemSync = /** @class */ (function (_super) { __extends(FileSystemSync, _super); function FileSystemSync() { @@ -83790,7 +83778,7 @@ exports.default = FileSystemSync; /***/ }), -/* 712 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83806,7 +83794,7 @@ exports.flatten = flatten; /***/ }), -/* 713 */ +/* 712 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83827,13 +83815,13 @@ exports.merge = merge; /***/ }), -/* 714 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const pathType = __webpack_require__(715); +const pathType = __webpack_require__(714); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -83899,13 +83887,13 @@ module.exports.sync = (input, opts) => { /***/ }), -/* 715 */ +/* 714 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const pify = __webpack_require__(716); +const pify = __webpack_require__(715); function type(fn, fn2, fp) { if (typeof fp !== 'string') { @@ -83948,7 +83936,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 716 */ +/* 715 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84039,17 +84027,17 @@ module.exports = (obj, opts) => { /***/ }), -/* 717 */ +/* 716 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); const path = __webpack_require__(4); -const fastGlob = __webpack_require__(528); -const gitIgnore = __webpack_require__(718); -const pify = __webpack_require__(719); -const slash = __webpack_require__(720); +const fastGlob = __webpack_require__(527); +const gitIgnore = __webpack_require__(717); +const pify = __webpack_require__(718); +const slash = __webpack_require__(719); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -84147,7 +84135,7 @@ module.exports.sync = options => { /***/ }), -/* 718 */ +/* 717 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -84616,7 +84604,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 719 */ +/* 718 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84691,7 +84679,7 @@ module.exports = (input, options) => { /***/ }), -/* 720 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84709,7 +84697,7 @@ module.exports = input => { /***/ }), -/* 721 */ +/* 720 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84722,7 +84710,7 @@ module.exports = input => { -var isGlob = __webpack_require__(722); +var isGlob = __webpack_require__(721); module.exports = function hasGlob(val) { if (val == null) return false; @@ -84742,7 +84730,7 @@ module.exports = function hasGlob(val) { /***/ }), -/* 722 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -84773,17 +84761,17 @@ module.exports = function isGlob(str) { /***/ }), -/* 723 */ +/* 722 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); const {constants: fsConstants} = __webpack_require__(134); -const pEvent = __webpack_require__(724); -const CpFileError = __webpack_require__(727); -const fs = __webpack_require__(729); -const ProgressEmitter = __webpack_require__(732); +const pEvent = __webpack_require__(723); +const CpFileError = __webpack_require__(726); +const fs = __webpack_require__(728); +const ProgressEmitter = __webpack_require__(731); const cpFileAsync = async (source, destination, options, progressEmitter) => { let readError; @@ -84897,12 +84885,12 @@ module.exports.sync = (source, destination, options) => { /***/ }), -/* 724 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pTimeout = __webpack_require__(725); +const pTimeout = __webpack_require__(724); const symbolAsyncIterator = Symbol.asyncIterator || '@@asyncIterator'; @@ -85193,12 +85181,12 @@ module.exports.iterator = (emitter, event, options) => { /***/ }), -/* 725 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pFinally = __webpack_require__(726); +const pFinally = __webpack_require__(725); class TimeoutError extends Error { constructor(message) { @@ -85244,7 +85232,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 726 */ +/* 725 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85266,12 +85254,12 @@ module.exports = (promise, onFinally) => { /***/ }), -/* 727 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(728); +const NestedError = __webpack_require__(727); class CpFileError extends NestedError { constructor(message, nested) { @@ -85285,7 +85273,7 @@ module.exports = CpFileError; /***/ }), -/* 728 */ +/* 727 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(112).inherits; @@ -85341,16 +85329,16 @@ module.exports = NestedError; /***/ }), -/* 729 */ +/* 728 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(112); const fs = __webpack_require__(133); -const makeDir = __webpack_require__(730); -const pEvent = __webpack_require__(724); -const CpFileError = __webpack_require__(727); +const makeDir = __webpack_require__(729); +const pEvent = __webpack_require__(723); +const CpFileError = __webpack_require__(726); const stat = promisify(fs.stat); const lstat = promisify(fs.lstat); @@ -85447,7 +85435,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 730 */ +/* 729 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85455,7 +85443,7 @@ exports.copyFileSync = (source, destination, flags) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); const {promisify} = __webpack_require__(112); -const semver = __webpack_require__(731); +const semver = __webpack_require__(730); const useNativeRecursiveOption = semver.satisfies(process.version, '>=10.12.0'); @@ -85610,7 +85598,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 731 */ +/* 730 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -87212,7 +87200,7 @@ function coerce (version, options) { /***/ }), -/* 732 */ +/* 731 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87253,7 +87241,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 733 */ +/* 732 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87299,12 +87287,12 @@ exports.default = module.exports; /***/ }), -/* 734 */ +/* 733 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pMap = __webpack_require__(735); +const pMap = __webpack_require__(734); const pFilter = async (iterable, filterer, options) => { const values = await pMap( @@ -87321,7 +87309,7 @@ module.exports.default = pFilter; /***/ }), -/* 735 */ +/* 734 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87400,12 +87388,12 @@ module.exports.default = pMap; /***/ }), -/* 736 */ +/* 735 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(728); +const NestedError = __webpack_require__(727); class CpyError extends NestedError { constructor(message, nested) { @@ -87419,14 +87407,14 @@ module.exports = CpyError; /***/ }), -/* 737 */ +/* 736 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const arrayUnion = __webpack_require__(526); +const arrayUnion = __webpack_require__(737); const glob = __webpack_require__(147); -const fastGlob = __webpack_require__(528); +const fastGlob = __webpack_require__(527); const dirGlob = __webpack_require__(738); const gitignore = __webpack_require__(742); @@ -87553,6 +87541,19 @@ module.exports.hasMagic = (patterns, opts) => [] module.exports.gitignore = gitignore; +/***/ }), +/* 737 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + +var arrayUniq = __webpack_require__(526); + +module.exports = function () { + return arrayUniq([].concat.apply([], arguments)); +}; + + /***/ }), /* 738 */ /***/ (function(module, exports, __webpack_require__) { @@ -87771,7 +87772,7 @@ module.exports = (obj, opts) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); -const fastGlob = __webpack_require__(528); +const fastGlob = __webpack_require__(527); const gitIgnore = __webpack_require__(743); const pify = __webpack_require__(741); const slash = __webpack_require__(744); @@ -88324,13 +88325,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildNonBazelProductionProjects", function() { return buildNonBazelProductionProjects; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "getProductionProjects", function() { return getProductionProjects; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildProject", function() { return buildProject; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(521); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(520); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(518); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(517); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(131); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(246); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(251); diff --git a/scripts/build_api_docs.js b/scripts/build_api_docs.js new file mode 100644 index 0000000000000..3e26a2d87895c --- /dev/null +++ b/scripts/build_api_docs.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +require('../src/setup_node_env'); +require('@kbn/docs-utils').runBuildApiDocsCli(); diff --git a/scripts/release_notes.js b/scripts/release_notes.js index f22c00f4643b0..7408ce322677c 100644 --- a/scripts/release_notes.js +++ b/scripts/release_notes.js @@ -7,4 +7,4 @@ */ require('../src/setup_node_env/no_transpilation'); -require('@kbn/release-notes').runReleaseNotesCli(); +require('@kbn/docs-utils').runReleaseNotesCli(); diff --git a/src/core/kibana.json b/src/core/kibana.json new file mode 100644 index 0000000000000..49f838dbc4ee5 --- /dev/null +++ b/src/core/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "core", + "summary": "The core plugin has core functionality", + "version": "kibana", + "serviceFolders": ["http", "saved_objects", "chrome", "application"] + } + \ No newline at end of file diff --git a/src/core/server/http/integration_tests/logging.test.ts b/src/core/server/http/integration_tests/logging.test.ts index fcf2cd2ba3372..62cb699bc49f6 100644 --- a/src/core/server/http/integration_tests/logging.test.ts +++ b/src/core/server/http/integration_tests/logging.test.ts @@ -251,7 +251,7 @@ describe('request logging', () => { expect(JSON.parse(meta).http.response.headers.bar).toBe('world'); }); - it('filters sensitive request headers', async () => { + it('filters sensitive request headers by default', async () => { const { http } = await root.setup(); http.createRouter('/').post( @@ -283,7 +283,139 @@ describe('request logging', () => { expect(JSON.parse(meta).http.request.headers.authorization).toBe('[REDACTED]'); }); - it('filters sensitive response headers', async () => { + it('filters sensitive request headers when RewriteAppender is configured', async () => { + root = kbnTestServer.createRoot({ + logging: { + silent: true, + appenders: { + 'test-console': { + type: 'console', + layout: { + type: 'pattern', + pattern: '%level|%logger|%message|%meta', + }, + }, + rewrite: { + type: 'rewrite', + appenders: ['test-console'], + policy: { + type: 'meta', + mode: 'update', + properties: [ + { path: 'http.request.headers.authorization', value: '[REDACTED]' }, + ], + }, + }, + }, + loggers: [ + { + name: 'http.server.response', + appenders: ['rewrite'], + level: 'debug', + }, + ], + }, + plugins: { + initialize: false, + }, + }); + const { http } = await root.setup(); + + http.createRouter('/').post( + { + path: '/ping', + validate: { + body: schema.object({ message: schema.string() }), + }, + options: { + authRequired: 'optional', + body: { + accepts: ['application/json'], + }, + timeout: { payload: 100 }, + }, + }, + (context, req, res) => res.ok({ body: { message: req.body.message } }) + ); + await root.start(); + + await kbnTestServer.request + .post(root, '/ping') + .set('content-type', 'application/json') + .set('authorization', 'abc') + .send({ message: 'hi' }) + .expect(200); + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + const [, , , meta] = mockConsoleLog.mock.calls[0][0].split('|'); + expect(JSON.parse(meta).http.request.headers.authorization).toBe('[REDACTED]'); + }); + + it('filters sensitive response headers by defaut', async () => { + const { http } = await root.setup(); + + http.createRouter('/').post( + { + path: '/ping', + validate: { + body: schema.object({ message: schema.string() }), + }, + options: { + authRequired: 'optional', + body: { + accepts: ['application/json'], + }, + timeout: { payload: 100 }, + }, + }, + (context, req, res) => + res.ok({ headers: { 'set-cookie': ['123'] }, body: { message: req.body.message } }) + ); + await root.start(); + + await kbnTestServer.request + .post(root, '/ping') + .set('Content-Type', 'application/json') + .send({ message: 'hi' }) + .expect(200); + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + const [, , , meta] = mockConsoleLog.mock.calls[0][0].split('|'); + expect(JSON.parse(meta).http.response.headers['set-cookie']).toBe('[REDACTED]'); + }); + + it('filters sensitive response headers when RewriteAppender is configured', async () => { + root = kbnTestServer.createRoot({ + logging: { + silent: true, + appenders: { + 'test-console': { + type: 'console', + layout: { + type: 'pattern', + pattern: '%level|%logger|%message|%meta', + }, + }, + rewrite: { + type: 'rewrite', + appenders: ['test-console'], + policy: { + type: 'meta', + mode: 'update', + properties: [{ path: 'http.response.headers.set-cookie', value: '[REDACTED]' }], + }, + }, + }, + loggers: [ + { + name: 'http.server.response', + appenders: ['rewrite'], + level: 'debug', + }, + ], + }, + plugins: { + initialize: false, + }, + }); const { http } = await root.setup(); http.createRouter('/').post( diff --git a/src/core/server/http/logging/get_response_log.test.ts b/src/core/server/http/logging/get_response_log.test.ts index 46c4f1d95e3be..64241ff44fc6b 100644 --- a/src/core/server/http/logging/get_response_log.test.ts +++ b/src/core/server/http/logging/get_response_log.test.ts @@ -171,6 +171,53 @@ describe('getEcsResponseLog', () => { }); test('does not mutate original headers', () => { + const reqHeaders = { a: 'foo', b: ['hello', 'world'] }; + const resHeaders = { headers: { c: 'bar' } }; + const req = createMockHapiRequest({ + headers: reqHeaders, + response: { headers: resHeaders }, + }); + + const responseLog = getEcsResponseLog(req, logger); + expect(reqHeaders).toMatchInlineSnapshot(` + Object { + "a": "foo", + "b": Array [ + "hello", + "world", + ], + } + `); + expect(resHeaders).toMatchInlineSnapshot(` + Object { + "headers": Object { + "c": "bar", + }, + } + `); + + responseLog.http.request.headers.a = 'testA'; + responseLog.http.request.headers.b[1] = 'testB'; + responseLog.http.request.headers.c = 'testC'; + expect(reqHeaders).toMatchInlineSnapshot(` + Object { + "a": "foo", + "b": Array [ + "hello", + "world", + ], + } + `); + expect(resHeaders).toMatchInlineSnapshot(` + Object { + "headers": Object { + "c": "bar", + }, + } + `); + }); + + test('does not mutate original headers when redacting sensitive data', () => { const reqHeaders = { authorization: 'a', cookie: 'b', 'user-agent': 'hi' }; const resHeaders = { headers: { 'content-length': 123, 'set-cookie': 'c' } }; const req = createMockHapiRequest({ diff --git a/src/core/server/http/logging/get_response_log.ts b/src/core/server/http/logging/get_response_log.ts index f75acde93bf40..57c02e05bebff 100644 --- a/src/core/server/http/logging/get_response_log.ts +++ b/src/core/server/http/logging/get_response_log.ts @@ -18,14 +18,22 @@ const ECS_VERSION = '1.7.0'; const FORBIDDEN_HEADERS = ['authorization', 'cookie', 'set-cookie']; const REDACTED_HEADER_TEXT = '[REDACTED]'; +type HapiHeaders = Record; + // We are excluding sensitive headers by default, until we have a log filtering mechanism. -function redactSensitiveHeaders( - headers?: Record -): Record { - const result = {} as Record; +function redactSensitiveHeaders(key: string, value: string | string[]): string | string[] { + return FORBIDDEN_HEADERS.includes(key) ? REDACTED_HEADER_TEXT : value; +} + +// Shallow clone the headers so they are not mutated if filtered by a RewriteAppender. +function cloneAndFilterHeaders(headers?: HapiHeaders) { + const result = {} as HapiHeaders; if (headers) { for (const key of Object.keys(headers)) { - result[key] = FORBIDDEN_HEADERS.includes(key) ? REDACTED_HEADER_TEXT : headers[key]; + result[key] = redactSensitiveHeaders( + key, + Array.isArray(headers[key]) ? [...headers[key]] : headers[key] + ); } } return result; @@ -45,7 +53,11 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { // eslint-disable-next-line @typescript-eslint/naming-convention const status_code = isBoom(response) ? response.output.statusCode : response.statusCode; - const responseHeaders = isBoom(response) ? response.output.headers : response.headers; + + const requestHeaders = cloneAndFilterHeaders(request.headers); + const responseHeaders = cloneAndFilterHeaders( + isBoom(response) ? (response.output.headers as HapiHeaders) : response.headers + ); // borrowed from the hapi/good implementation const responseTime = (request.info.completed || request.info.responded) - request.info.received; @@ -66,7 +78,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { mime_type: request.mime, referrer: request.info.referrer, // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232. - headers: redactSensitiveHeaders(request.headers), + headers: requestHeaders, }, response: { body: { @@ -74,7 +86,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta { }, status_code, // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232. - headers: redactSensitiveHeaders(responseHeaders), + headers: responseHeaders, // responseTime is a custom non-ECS field responseTime: !isNaN(responseTime) ? responseTime : undefined, }, diff --git a/src/core/server/logging/README.mdx b/src/core/server/logging/README.mdx index 8c093d0231585..1575e67d7b8ee 100644 --- a/src/core/server/logging/README.mdx +++ b/src/core/server/logging/README.mdx @@ -278,6 +278,124 @@ The maximum number of files to keep. Once this number is reached, oldest files w The default value is `7` +### Rewrite Appender + +*This appender is currently considered experimental and is not intended +for public consumption. The API is subject to change at any time.* + +Similar to log4j's `RewriteAppender`, this appender serves as a sort of middleware, +modifying the provided log events before passing them along to another +appender. + +```yaml +logging: + appenders: + my-rewrite-appender: + type: rewrite + appenders: [console, file] # name of "destination" appender(s) + policy: + # ... +``` + +The most common use case for the `RewriteAppender` is when you want to +filter or censor sensitive data that may be contained in a log entry. +In fact, with a default configuration, Kibana will automatically redact +any `authorization`, `cookie`, or `set-cookie` headers when logging http +requests & responses. + +To configure additional rewrite rules, you'll need to specify a `RewritePolicy`. + +#### Rewrite Policies + +Rewrite policies exist to indicate which parts of a log record can be +modified within the rewrite appender. + +**Meta** + +The `meta` rewrite policy can read and modify any data contained in the +`LogMeta` before passing it along to a destination appender. + +Meta policies must specify one of three modes, which indicate which action +to perform on the configured properties: +- `update` updates an existing property at the provided `path`. +- `remove` removes an existing property at the provided `path`. + +The `properties` are listed as a `path` and `value` pair, where `path` is +the dot-delimited path to the target property in the `LogMeta` object, and +`value` is the value to add or update in that target property. When using +the `remove` mode, a `value` is not necessary. + +Here's an example of how you would replace any `cookie` header values with `[REDACTED]`: + +```yaml +logging: + appenders: + my-rewrite-appender: + type: rewrite + appenders: [console] + policy: + type: meta # indicates that we want to rewrite the LogMeta + mode: update # will update an existing property only + properties: + - path: "http.request.headers.cookie" # path to property + value: "[REDACTED]" # value to replace at path +``` + +Rewrite appenders can even be passed to other rewrite appenders to apply +multiple filter policies/modes, as long as it doesn't create a circular +reference. Each rewrite appender is applied sequentially (one after the other). +```yaml +logging: + appenders: + remove-request-headers: + type: rewrite + appenders: [censor-response-headers] # redirect to the next rewrite appender + policy: + type: meta + mode: remove + properties: + - path: "http.request.headers" # remove all request headers + censor-response-headers: + type: rewrite + appenders: [console] # output to console + policy: + type: meta + mode: update + properties: + - path: "http.response.headers.set-cookie" + value: "[REDACTED]" +``` + +#### Complete Example +```yaml +logging: + appenders: + console: + type: console + layout: + type: pattern + highlight: true + pattern: "[%date][%level][%logger] %message %meta" + file: + type: file + fileName: ./kibana.log + layout: + type: json + censor: + type: rewrite + appenders: [console, file] + policy: + type: meta + mode: update + properties: + - path: "http.request.headers.cookie" + value: "[REDACTED]" + loggers: + - name: http.server.response + appenders: [censor] # pass these logs to our rewrite appender + level: debug +``` + ## Configuration As any configuration in the platform, logging configuration is validated against the predefined schema and if there are diff --git a/src/core/server/logging/appenders/appenders.ts b/src/core/server/logging/appenders/appenders.ts index a41a6a2f68fa1..88df355bd5ebe 100644 --- a/src/core/server/logging/appenders/appenders.ts +++ b/src/core/server/logging/appenders/appenders.ts @@ -17,6 +17,7 @@ import { import { Layouts } from '../layouts/layouts'; import { ConsoleAppender, ConsoleAppenderConfig } from './console/console_appender'; import { FileAppender, FileAppenderConfig } from './file/file_appender'; +import { RewriteAppender, RewriteAppenderConfig } from './rewrite/rewrite_appender'; import { RollingFileAppender, RollingFileAppenderConfig, @@ -32,6 +33,7 @@ export const appendersSchema = schema.oneOf([ ConsoleAppender.configSchema, FileAppender.configSchema, LegacyAppender.configSchema, + RewriteAppender.configSchema, RollingFileAppender.configSchema, ]); @@ -40,6 +42,7 @@ export type AppenderConfigType = | ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig + | RewriteAppenderConfig | RollingFileAppenderConfig; /** @internal */ @@ -57,6 +60,8 @@ export class Appenders { return new ConsoleAppender(Layouts.create(config.layout)); case 'file': return new FileAppender(Layouts.create(config.layout), config.fileName); + case 'rewrite': + return new RewriteAppender(config); case 'rolling-file': return new RollingFileAppender(config); case 'legacy-appender': diff --git a/src/core/server/logging/appenders/rewrite/mocks.ts b/src/core/server/logging/appenders/rewrite/mocks.ts new file mode 100644 index 0000000000000..a19756e25bf8e --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/mocks.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RewritePolicy } from './policies/policy'; + +const createPolicyMock = () => { + const mock: jest.Mocked = { + rewrite: jest.fn((x) => x), + }; + return mock; +}; + +export const rewriteAppenderMocks = { + createPolicy: createPolicyMock, +}; diff --git a/src/core/server/logging/appenders/rewrite/policies/index.ts b/src/core/server/logging/appenders/rewrite/policies/index.ts new file mode 100644 index 0000000000000..ae3be1e4de916 --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { assertNever } from '@kbn/std'; +import { RewritePolicy } from './policy'; +import { MetaRewritePolicy, MetaRewritePolicyConfig, metaRewritePolicyConfigSchema } from './meta'; + +export { RewritePolicy }; + +/** + * Available rewrite policies which specify what part of a {@link LogRecord} + * can be modified. + */ +export type RewritePolicyConfig = MetaRewritePolicyConfig; + +export const rewritePolicyConfigSchema = metaRewritePolicyConfigSchema; + +export const createRewritePolicy = (config: RewritePolicyConfig): RewritePolicy => { + switch (config.type) { + case 'meta': + return new MetaRewritePolicy(config); + default: + return assertNever(config.type); + } +}; diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/index.ts b/src/core/server/logging/appenders/rewrite/policies/meta/index.ts new file mode 100644 index 0000000000000..afdfd6fb709d3 --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/meta/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { + MetaRewritePolicy, + MetaRewritePolicyConfig, + metaRewritePolicyConfigSchema, +} from './meta_policy'; diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts new file mode 100644 index 0000000000000..52b88331a75be --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LogRecord, LogLevel, LogMeta } from '@kbn/logging'; +import { MetaRewritePolicy, MetaRewritePolicyConfig } from './meta_policy'; + +describe('MetaRewritePolicy', () => { + const createPolicy = ( + mode: MetaRewritePolicyConfig['mode'], + properties: MetaRewritePolicyConfig['properties'] + ) => new MetaRewritePolicy({ type: 'meta', mode, properties }); + + const createLogRecord = (meta: LogMeta = {}): LogRecord => ({ + timestamp: new Date(Date.UTC(2012, 1, 1, 14, 30, 22, 11)), + level: LogLevel.Info, + context: 'context', + message: 'just a log', + pid: 42, + meta, + }); + + describe('mode: update', () => { + it('updates existing properties in LogMeta', () => { + const log = createLogRecord({ a: 'before' }); + const policy = createPolicy('update', [{ path: 'a', value: 'after' }]); + expect(policy.rewrite(log).meta!.a).toBe('after'); + }); + + it('updates nested properties in LogMeta', () => { + const log = createLogRecord({ a: 'before a', b: { c: 'before b.c' }, d: [0, 1] }); + const policy = createPolicy('update', [ + { path: 'a', value: 'after a' }, + { path: 'b.c', value: 'after b.c' }, + { path: 'd[1]', value: 2 }, + ]); + expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` + Object { + "a": "after a", + "b": Object { + "c": "after b.c", + }, + "d": Array [ + 0, + 2, + ], + } + `); + }); + + it('handles string, number, boolean, null', () => { + const policy = createPolicy('update', [ + { path: 'a', value: false }, + { path: 'b', value: null }, + { path: 'c', value: 123 }, + { path: 'd', value: 'hi' }, + ]); + const log = createLogRecord({ + a: 'a', + b: 'b', + c: 'c', + d: 'd', + }); + expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` + Object { + "a": false, + "b": null, + "c": 123, + "d": "hi", + } + `); + }); + + it(`does not add properties which don't exist yet`, () => { + const policy = createPolicy('update', [ + { path: 'a.b', value: 'foo' }, + { path: 'a.c', value: 'bar' }, + ]); + const log = createLogRecord({ a: { b: 'existing meta' } }); + const { meta } = policy.rewrite(log); + expect(meta!.a.b).toBe('foo'); + expect(meta!.a.c).toBeUndefined(); + }); + + it('does not touch anything outside of LogMeta', () => { + const policy = createPolicy('update', [{ path: 'a', value: 'bar' }]); + const message = Symbol(); + expect( + policy.rewrite(({ message, meta: { a: 'foo' } } as unknown) as LogRecord).message + ).toBe(message); + expect(policy.rewrite(({ message, meta: { a: 'foo' } } as unknown) as LogRecord)) + .toMatchInlineSnapshot(` + Object { + "message": Symbol(), + "meta": Object { + "a": "bar", + }, + } + `); + }); + }); + + describe('mode: remove', () => { + it('removes existing properties in LogMeta', () => { + const log = createLogRecord({ a: 'goodbye' }); + const policy = createPolicy('remove', [{ path: 'a' }]); + expect(policy.rewrite(log).meta!.a).toBeUndefined(); + }); + + it('removes nested properties in LogMeta', () => { + const log = createLogRecord({ a: 'a', b: { c: 'b.c' }, d: [0, 1] }); + const policy = createPolicy('remove', [{ path: 'b.c' }, { path: 'd[1]' }]); + expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` + Object { + "a": "a", + "b": Object {}, + "d": Array [ + 0, + undefined, + ], + } + `); + }); + + it('has no effect if property does not exist', () => { + const log = createLogRecord({ a: 'a' }); + const policy = createPolicy('remove', [{ path: 'b' }]); + expect(policy.rewrite(log).meta).toMatchInlineSnapshot(` + Object { + "a": "a", + } + `); + }); + + it('does not touch anything outside of LogMeta', () => { + const policy = createPolicy('remove', [{ path: 'message' }]); + const message = Symbol(); + expect( + policy.rewrite(({ message, meta: { message: 'foo' } } as unknown) as LogRecord).message + ).toBe(message); + expect(policy.rewrite(({ message, meta: { message: 'foo' } } as unknown) as LogRecord)) + .toMatchInlineSnapshot(` + Object { + "message": Symbol(), + "meta": Object {}, + } + `); + }); + }); +}); diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.ts b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.ts new file mode 100644 index 0000000000000..2215b3489539f --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { LogRecord } from '@kbn/logging'; +import { set } from '@elastic/safer-lodash-set'; +import { has, unset } from 'lodash'; +import { assertNever } from '@kbn/std'; +import { RewritePolicy } from '../policy'; + +type MetaRewritePolicyConfigProperties = Array<{ + path: string; + value?: string | number | boolean | null; +}>; + +export interface MetaRewritePolicyConfig { + type: 'meta'; + + /** + * The 'mode' specifies what action to perform on the specified properties. + * - 'update' updates an existing property at the provided 'path'. + * - 'remove' removes an existing property at the provided 'path'. + */ + mode: 'remove' | 'update'; + + /** + * The properties to modify. + * + * @remarks + * Each provided 'path' is relative to the record's {@link LogMeta}. + * For the 'remove' mode, no 'value' is provided. + */ + properties: MetaRewritePolicyConfigProperties; +} + +export const metaRewritePolicyConfigSchema = schema.object({ + type: schema.literal('meta'), + mode: schema.oneOf([schema.literal('update'), schema.literal('remove')], { + defaultValue: 'update', + }), + properties: schema.arrayOf( + schema.object({ + path: schema.string(), + value: schema.maybe( + schema.nullable(schema.oneOf([schema.string(), schema.number(), schema.boolean()])) + ), + }) + ), +}); + +/** + * A rewrite policy which can add, remove, or update properties + * from a record's {@link LogMeta}. + */ +export class MetaRewritePolicy implements RewritePolicy { + constructor(private readonly config: MetaRewritePolicyConfig) {} + + rewrite(record: LogRecord): LogRecord { + switch (this.config.mode) { + case 'update': + return this.update(record); + case 'remove': + return this.remove(record); + default: + return assertNever(this.config.mode); + } + } + + private update(record: LogRecord) { + for (const { path, value } of this.config.properties) { + if (!has(record, `meta.${path}`)) { + continue; // don't add properties which don't already exist + } + set(record, `meta.${path}`, value); + } + return record; + } + + private remove(record: LogRecord) { + for (const { path } of this.config.properties) { + unset(record, `meta.${path}`); + } + return record; + } +} diff --git a/src/core/server/logging/appenders/rewrite/policies/policy.ts b/src/core/server/logging/appenders/rewrite/policies/policy.ts new file mode 100644 index 0000000000000..f8aef887965fd --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/policies/policy.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { LogRecord } from '@kbn/logging'; + +/** + * Rewrites a {@link LogRecord} based on the policy's configuration. + **/ +export interface RewritePolicy { + rewrite(record: LogRecord): LogRecord; +} diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.mocks.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.mocks.ts new file mode 100644 index 0000000000000..9d29a68305792 --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.mocks.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; + +export const createRewritePolicyMock = jest.fn(); +jest.doMock('./policies', () => ({ + rewritePolicyConfigSchema: schema.any(), + createRewritePolicy: createRewritePolicyMock, +})); + +export const resetAllMocks = () => { + createRewritePolicyMock.mockReset(); +}; diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts new file mode 100644 index 0000000000000..72a54b5012ce5 --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { MockedKeys } from '@kbn/utility-types/jest'; +import { createRewritePolicyMock, resetAllMocks } from './rewrite_appender.test.mocks'; +import { rewriteAppenderMocks } from './mocks'; +import { LogLevel, LogRecord, LogMeta, DisposableAppender } from '@kbn/logging'; +import { RewriteAppender, RewriteAppenderConfig } from './rewrite_appender'; + +// Helper to ensure tuple is typed [A, B] instead of Array +const toTuple = (a: A, b: B): [A, B] => [a, b]; + +const createAppenderMock = (name: string) => { + const appenderMock: MockedKeys = { + append: jest.fn(), + dispose: jest.fn(), + }; + + return toTuple(name, appenderMock); +}; + +const createConfig = (appenderNames: string[]): RewriteAppenderConfig => ({ + type: 'rewrite', + appenders: appenderNames, + policy: { + type: 'meta', + mode: 'update', + properties: [{ path: 'foo', value: 'bar' }], + }, +}); + +const createLogRecord = (meta: LogMeta = {}): LogRecord => ({ + timestamp: new Date(), + level: LogLevel.Info, + context: 'context', + message: 'just a log', + pid: 42, + meta, +}); + +describe('RewriteAppender', () => { + let policy: ReturnType; + + beforeEach(() => { + policy = rewriteAppenderMocks.createPolicy(); + createRewritePolicyMock.mockReturnValue(policy); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + resetAllMocks(); + }); + + it('creates a rewrite policy with the provided config', () => { + const config = createConfig([]); + new RewriteAppender(config); + expect(createRewritePolicyMock).toHaveBeenCalledTimes(1); + expect(createRewritePolicyMock).toHaveBeenCalledWith(config.policy); + }); + + describe('#addAppender', () => { + it('updates the map of available appenders', () => { + const config = createConfig(['mock1']); + const appender = new RewriteAppender(config); + appender.addAppender(...createAppenderMock('mock1')); + expect(() => { + appender.append(createLogRecord()); + }).not.toThrowError(); + }); + }); + + describe('#append', () => { + it('calls the configured appenders with the provided LogRecord', () => { + const config = createConfig(['mock1', 'mock2']); + const appenderMocks = [createAppenderMock('mock1'), createAppenderMock('mock2')]; + + const appender = new RewriteAppender(config); + appenderMocks.forEach((mock) => appender.addAppender(...mock)); + + const log1 = createLogRecord({ a: 'b' }); + const log2 = createLogRecord({ c: 'd' }); + + appender.append(log1); + + expect(appenderMocks[0][1].append).toHaveBeenCalledTimes(1); + expect(appenderMocks[1][1].append).toHaveBeenCalledTimes(1); + expect(appenderMocks[0][1].append).toHaveBeenCalledWith(log1); + expect(appenderMocks[1][1].append).toHaveBeenCalledWith(log1); + + appender.append(log2); + + expect(appenderMocks[0][1].append).toHaveBeenCalledTimes(2); + expect(appenderMocks[1][1].append).toHaveBeenCalledTimes(2); + expect(appenderMocks[0][1].append).toHaveBeenCalledWith(log2); + expect(appenderMocks[1][1].append).toHaveBeenCalledWith(log2); + }); + + it('calls `rewrite` on the configured policy', () => { + const config = createConfig(['mock1']); + + const appender = new RewriteAppender(config); + appender.addAppender(...createAppenderMock('mock1')); + + const log1 = createLogRecord({ a: 'b' }); + const log2 = createLogRecord({ c: 'd' }); + + appender.append(log1); + + expect(policy.rewrite).toHaveBeenCalledTimes(1); + expect(policy.rewrite.mock.calls).toEqual([[log1]]); + + appender.append(log2); + + expect(policy.rewrite).toHaveBeenCalledTimes(2); + expect(policy.rewrite.mock.calls).toEqual([[log1], [log2]]); + }); + + it('throws if an appender key cannot be found', () => { + const config = createConfig(['oops']); + const appender = new RewriteAppender(config); + + expect(() => { + appender.append(createLogRecord()); + }).toThrowErrorMatchingInlineSnapshot( + `"Rewrite Appender could not find appender key \\"oops\\". Be sure \`appender.addAppender()\` was called before \`appender.append()\`."` + ); + }); + }); +}); diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.ts new file mode 100644 index 0000000000000..e54d8ba40ebfc --- /dev/null +++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { LogRecord, Appender, DisposableAppender } from '@kbn/logging'; +import { + createRewritePolicy, + rewritePolicyConfigSchema, + RewritePolicy, + RewritePolicyConfig, +} from './policies'; + +export interface RewriteAppenderConfig { + type: 'rewrite'; + /** + * The {@link Appender | appender(s)} to pass the log event to after + * implementing the specified rewrite policy. + */ + appenders: string[]; + /** + * The {@link RewritePolicy | policy} to use to manipulate the provided data. + */ + policy: RewritePolicyConfig; +} + +/** + * Appender that can modify the `LogRecord` instances it receives before passing + * them along to another {@link Appender}. + * @internal + */ +export class RewriteAppender implements DisposableAppender { + public static configSchema = schema.object({ + type: schema.literal('rewrite'), + appenders: schema.arrayOf(schema.string(), { defaultValue: [] }), + policy: rewritePolicyConfigSchema, + }); + + private appenders: Map = new Map(); + private readonly policy: RewritePolicy; + + constructor(private readonly config: RewriteAppenderConfig) { + this.policy = createRewritePolicy(config.policy); + } + + /** + * List of appenders that are dependencies of this appender. + * + * `addAppender` will throw an error when called with an appender + * reference that isn't in this list. + */ + public get appenderRefs() { + return this.config.appenders; + } + + /** + * Appenders can be "attached" to this one so that the RewriteAppender + * is able to act as a sort of middleware by calling `append` on other appenders. + * + * As appenders cannot be attached to each other until they are created, + * the `addAppender` method is used to pass in a configured appender. + */ + public addAppender(appenderRef: string, appender: Appender) { + if (!this.appenderRefs.includes(appenderRef)) { + throw new Error( + `addAppender was called with an appender key that is missing from the appenderRefs: "${appenderRef}".` + ); + } + + this.appenders.set(appenderRef, appender); + } + + /** + * Modifies the `record` and passes it to the specified appender. + */ + public append(record: LogRecord) { + const rewrittenRecord = this.policy.rewrite(record); + for (const appenderRef of this.appenderRefs) { + const appender = this.appenders.get(appenderRef); + if (!appender) { + throw new Error( + `Rewrite Appender could not find appender key "${appenderRef}". ` + + 'Be sure `appender.addAppender()` was called before `appender.append()`.' + ); + } + appender.append(rewrittenRecord); + } + } + + /** + * Disposes `RewriteAppender`. + */ + public dispose() { + this.appenders.clear(); + } +} diff --git a/src/core/server/logging/logging_config.test.ts b/src/core/server/logging/logging_config.test.ts index 2cb5831a8fb4c..83f3c139e371a 100644 --- a/src/core/server/logging/logging_config.test.ts +++ b/src/core/server/logging/logging_config.test.ts @@ -78,7 +78,6 @@ test('correctly fills in custom `appenders` config.', () => { type: 'console', layout: { type: 'pattern', highlight: true }, }); - expect(configValue.appenders.get('console')).toEqual({ type: 'console', layout: { type: 'pattern' }, diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index f68d6c6a97fbc..8a6fe71bc6222 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -134,6 +134,76 @@ test('uses `root` logger if context name is not specified.', async () => { expect(mockConsoleLog.mock.calls).toMatchSnapshot(); }); +test('attaches appenders to appenders that declare refs', async () => { + await system.upgrade( + config.schema.validate({ + appenders: { + console: { + type: 'console', + layout: { type: 'pattern', pattern: '[%logger] %message %meta' }, + }, + file: { + type: 'file', + layout: { type: 'pattern', pattern: '[%logger] %message %meta' }, + fileName: 'path', + }, + rewrite: { + type: 'rewrite', + appenders: ['console', 'file'], + policy: { type: 'meta', mode: 'remove', properties: [{ path: 'b' }] }, + }, + }, + loggers: [{ name: 'tests', level: 'warn', appenders: ['rewrite'] }], + }) + ); + + const testLogger = system.get('tests'); + testLogger.warn('This message goes to a test context.', { a: 'hi', b: 'remove me' }); + + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog.mock.calls[0][0]).toMatchInlineSnapshot( + `"[tests] This message goes to a test context. {\\"a\\":\\"hi\\"}"` + ); + + expect(mockStreamWrite).toHaveBeenCalledTimes(1); + expect(mockStreamWrite.mock.calls[0][0]).toMatchInlineSnapshot(` + "[tests] This message goes to a test context. {\\"a\\":\\"hi\\"} + " + `); +}); + +test('throws if a circular appender reference is detected', async () => { + expect(async () => { + await system.upgrade( + config.schema.validate({ + appenders: { + console: { type: 'console', layout: { type: 'pattern' } }, + a: { + type: 'rewrite', + appenders: ['b'], + policy: { type: 'meta', mode: 'remove', properties: [{ path: 'b' }] }, + }, + b: { + type: 'rewrite', + appenders: ['c'], + policy: { type: 'meta', mode: 'remove', properties: [{ path: 'b' }] }, + }, + c: { + type: 'rewrite', + appenders: ['console', 'a'], + policy: { type: 'meta', mode: 'remove', properties: [{ path: 'b' }] }, + }, + }, + loggers: [{ name: 'tests', level: 'warn', appenders: ['a'] }], + }) + ); + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Circular appender reference detected: [b -> c -> a -> b]"` + ); + + expect(mockConsoleLog).toHaveBeenCalledTimes(0); +}); + test('`stop()` disposes all appenders.', async () => { await system.upgrade( config.schema.validate({ diff --git a/src/core/server/logging/logging_system.ts b/src/core/server/logging/logging_system.ts index 9ae434aff41d3..d7c34b48c4101 100644 --- a/src/core/server/logging/logging_system.ts +++ b/src/core/server/logging/logging_system.ts @@ -146,6 +146,26 @@ export class LoggingSystem implements LoggerFactory { return this.getLoggerConfigByContext(config, LoggingConfig.getParentLoggerContext(context)); } + /** + * Retrieves an appender by the provided key, after first checking that no circular + * dependencies exist between appender refs. + */ + private getAppenderByRef(appenderRef: string) { + const checkCircularRefs = (key: string, stack: string[]) => { + if (stack.includes(key)) { + throw new Error(`Circular appender reference detected: [${stack.join(' -> ')} -> ${key}]`); + } + stack.push(key); + const appender = this.appenders.get(key); + if (appender?.appenderRefs) { + appender.appenderRefs.forEach((ref) => checkCircularRefs(ref, [...stack])); + } + return appender; + }; + + return checkCircularRefs(appenderRef, []); + } + private async applyBaseConfig(newBaseConfig: LoggingConfig) { const computedConfig = [...this.contextConfigs.values()].reduce( (baseConfig, contextConfig) => baseConfig.extend(contextConfig), @@ -167,6 +187,23 @@ export class LoggingSystem implements LoggerFactory { this.appenders.set(appenderKey, Appenders.create(appenderConfig)); } + // Once all appenders have been created, check for any that have explicitly + // declared `appenderRefs` dependencies, and look up those dependencies to + // attach to the appender. This enables appenders to act as a sort of + // middleware and call `append` on each other if needed. + for (const [key, appender] of this.appenders) { + if (!appender.addAppender || !appender.appenderRefs) { + continue; + } + for (const ref of appender.appenderRefs) { + const foundAppender = this.getAppenderByRef(ref); + if (!foundAppender) { + throw new Error(`Appender "${key}" config contains unknown appender key "${ref}".`); + } + appender.addAppender(ref, foundAppender); + } + } + for (const [loggerKey, loggerAdapter] of this.loggers) { loggerAdapter.updateLogger(this.createLogger(loggerKey, computedConfig)); } diff --git a/src/core/server/plugins/discovery/plugin_manifest_parser.ts b/src/core/server/plugins/discovery/plugin_manifest_parser.ts index eae0e73e86c46..7ac629534ba08 100644 --- a/src/core/server/plugins/discovery/plugin_manifest_parser.ts +++ b/src/core/server/plugins/discovery/plugin_manifest_parser.ts @@ -47,6 +47,7 @@ const KNOWN_MANIFEST_FIELDS = (() => { server: true, extraPublicDirs: true, requiredBundles: true, + serviceFolders: true, }; return new Set(Object.keys(manifestFields)); diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 45db98201b758..a6086bd6f17e8 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -169,6 +169,12 @@ export interface PluginManifest { * @deprecated */ readonly extraPublicDirs?: string[]; + + /** + * Only used for the automatically generated API documentation. Specifying service + * folders will cause your plugin API reference to be broken up into sub sections. + */ + readonly serviceFolders?: readonly string[]; } /** diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index fc26c837d5e52..267d671361184 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -96,8 +96,7 @@ describe('getSearchDsl', () => { mappings, opts.type, opts.sortField, - opts.sortOrder, - opts.pit + opts.sortOrder ); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index cae5e43897bcf..9820544f02bd1 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -78,7 +78,7 @@ export function getSearchDsl( hasReferenceOperator, kueryNode, }), - ...getSortingParams(mappings, type, sortField, sortOrder, pit), + ...getSortingParams(mappings, type, sortField, sortOrder), ...(pit ? getPitParams(pit) : {}), ...(searchAfter ? { search_after: searchAfter } : {}), }; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts index 73c7065705fc5..1376f0d50a9da 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts @@ -79,11 +79,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect(getSortingParams(MAPPINGS, 'saved', 'title', undefined, { id: 'abc' }).sort).toEqual( - expect.arrayContaining([{ _shard_doc: 'asc' }]) - ); - }); }); describe('sortField is simple root property with multiple types', () => { it('returns correct params', () => { @@ -98,11 +93,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved', 'pending'], 'type', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is simple non-root property with multiple types', () => { it('returns correct params', () => { @@ -124,11 +114,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, 'saved', 'title.raw', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is multi-field with single type as array', () => { it('returns correct params', () => { @@ -143,11 +128,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved'], 'title.raw', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is root multi-field with multiple types', () => { it('returns correct params', () => { @@ -162,12 +142,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved', 'pending'], 'type.raw', undefined, { id: 'abc' }) - .sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is not-root multi-field with multiple types', () => { it('returns correct params', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index abef9bfa0a300..e3bfba6a80f59 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -8,12 +8,6 @@ import Boom from '@hapi/boom'; import { getProperty, IndexMapping } from '../../../mappings'; -import { SavedObjectsPitParams } from '../../../types'; - -// TODO: The plan is for ES to automatically add this tiebreaker when -// using PIT. We should remove this logic once that is resolved. -// https://github.com/elastic/elasticsearch/issues/56828 -const ES_PROVIDED_TIEBREAKER = { _shard_doc: 'asc' }; const TOP_LEVEL_FIELDS = ['_id', '_score']; @@ -21,8 +15,7 @@ export function getSortingParams( mappings: IndexMapping, type: string | string[], sortField?: string, - sortOrder?: string, - pit?: SavedObjectsPitParams + sortOrder?: string ) { if (!sortField) { return {}; @@ -38,7 +31,6 @@ export function getSortingParams( order: sortOrder, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -59,7 +51,6 @@ export function getSortingParams( unmapped_type: rootField.type, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -84,7 +75,6 @@ export function getSortingParams( unmapped_type: field.type, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 2177da84b2b53..72d66bc04f08e 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -180,10 +180,11 @@ export interface AppCategory { // Warning: (ae-forgotten-export) The symbol "ConsoleAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "FileAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "LegacyAppenderConfig" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RewriteAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RollingFileAppenderConfig" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RollingFileAppenderConfig; +export type AppenderConfigType = ConsoleAppenderConfig | FileAppenderConfig | LegacyAppenderConfig | RewriteAppenderConfig | RollingFileAppenderConfig; // @public @deprecated (undocumented) export interface AssistanceAPIResponse { @@ -1882,6 +1883,7 @@ export interface PluginManifest { readonly requiredBundles: readonly string[]; readonly requiredPlugins: readonly PluginName[]; readonly server: boolean; + readonly serviceFolders?: readonly string[]; readonly ui: boolean; readonly version: string; } @@ -3197,9 +3199,9 @@ export const validBodyOutput: readonly ["data", "stream"]; // Warnings were encountered during analysis: // // src/core/server/http/router/response.ts:297:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:280:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:280:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:283:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:388:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "create" +// src/core/server/plugins/types.ts:286:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:286:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:289:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:394:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "create" ``` diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json index 0d5cfded88f75..cbfed6741f8a4 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json @@ -1,6 +1,7 @@ { "cluster.get_component_template": { "url_params": { + "flat_settings": "__flag__", "master_timeout": "", "local": "__flag__" }, @@ -11,6 +12,6 @@ "_component_template", "_component_template/{name}" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-template.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/getting-component-templates.html" } } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json index a7b7642fac0b0..453d99af24efd 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json @@ -2,12 +2,10 @@ "cluster.put_component_template": { "url_params": { "create": "__flag__", - "timeout": "", "master_timeout": "" }, "methods": [ - "PUT", - "POST" + "PUT" ], "patterns": [ "_component_template/{name}" diff --git a/src/plugins/data/kibana.json b/src/plugins/data/kibana.json index a09ab12f0c6f0..452b081d6387f 100644 --- a/src/plugins/data/kibana.json +++ b/src/plugins/data/kibana.json @@ -10,6 +10,7 @@ "share", "inspector" ], + "serviceFolders": ["search", "index_patterns", "query", "autocomplete", "ui", "field_formats"], "optionalPlugins": ["usageCollection"], "extraPublicDirs": ["common"], "requiredBundles": [ diff --git a/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts index 5517011f05718..8c7e48f173031 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts @@ -59,6 +59,7 @@ export function isFieldFiltered( const scriptedOrMissing = !filterState.missing || field.type === '_source' || + field.type === 'unknown_selected' || field.scripted || fieldCounts[field.name] > 0; const needle = filterState.name ? filterState.name.toLowerCase() : ''; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts index 9792e98ba84c7..68099fb0c8e2a 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts @@ -217,6 +217,20 @@ describe('group_fields', function () { ]); }); + it('should filter fields by a given name', function () { + const fieldFilterState = { ...getDefaultFieldFilter(), ...{ name: 'curr' } }; + + const actual1 = groupFields( + fields as IndexPatternField[], + ['customer_birth_date', 'currency', 'unknown'], + 5, + fieldCounts, + fieldFilterState, + false + ); + expect(actual1.selected.map((field) => field.name)).toEqual(['currency']); + }); + it('excludes unmapped fields if showUnmappedFields set to false', function () { const fieldFilterState = getDefaultFieldFilter(); const fieldsWithUnmappedField = [...fields]; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx index eefb96b78aac6..dc6cbcedc8086 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx @@ -71,10 +71,18 @@ export function groupFields( } } } - // add columns, that are not part of the index pattern, to be removeable + // add selected columns, that are not part of the index pattern, to be removeable for (const column of columns) { - if (!result.selected.find((field) => field.name === column)) { - result.selected.push({ name: column, displayName: column } as IndexPatternField); + const tmpField = { + name: column, + displayName: column, + type: 'unknown_selected', + } as IndexPatternField; + if ( + !result.selected.find((field) => field.name === column) && + isFieldFiltered(tmpField, fieldFilterState, fieldCounts) + ) { + result.selected.push(tmpField); } } result.selected.sort((a, b) => { diff --git a/src/plugins/discover/public/application/helpers/persist_saved_search.ts b/src/plugins/discover/public/application/helpers/persist_saved_search.ts index 2e4ab90ee58e5..f44d4650da56a 100644 --- a/src/plugins/discover/public/application/helpers/persist_saved_search.ts +++ b/src/plugins/discover/public/application/helpers/persist_saved_search.ts @@ -49,7 +49,7 @@ export async function persistSavedSearch( if (state.grid) { savedSearch.grid = state.grid; } - if (state.hideChart) { + if (typeof state.hideChart !== 'undefined') { savedSearch.hideChart = state.hideChart; } diff --git a/src/plugins/embeddable/README.asciidoc b/src/plugins/embeddable/README.asciidoc index daa6040eab7eb..165dc37c56cb3 100644 --- a/src/plugins/embeddable/README.asciidoc +++ b/src/plugins/embeddable/README.asciidoc @@ -22,18 +22,17 @@ There is also an example of rendering dashboard container outside of dashboard a === Docs -link:./docs/README.md[Embeddable docs, guides & caveats] +link:https://github.com/elastic/kibana/blob/master/src/plugins/embeddable/docs/README.md[Embeddable docs, guides & caveats] === API docs -==== Server API -https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md[Server Setup contract] -https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablestart.md[Server Start contract] - -===== Browser API +==== Browser API https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablesetup.md[Browser Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestart.md[Browser Start contract] +==== Server API +https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md[Server Setup contract] + === Testing Run unit tests diff --git a/src/plugins/expressions/README.asciidoc b/src/plugins/expressions/README.asciidoc index e07f6e2909ab8..554e3bfcb6976 100644 --- a/src/plugins/expressions/README.asciidoc +++ b/src/plugins/expressions/README.asciidoc @@ -46,7 +46,7 @@ image::https://user-images.githubusercontent.com/9773803/74162514-3250a880-4c21- https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionsserversetup.md[Server Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionsserverstart.md[Server Start contract] -===== Browser API +==== Browser API https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicesetup.md[Browser Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsstart.md[Browser Start contract] diff --git a/src/plugins/expressions/common/expression_functions/specs/index.ts b/src/plugins/expressions/common/expression_functions/specs/index.ts index 938fce026027c..9408b3a433712 100644 --- a/src/plugins/expressions/common/expression_functions/specs/index.ts +++ b/src/plugins/expressions/common/expression_functions/specs/index.ts @@ -15,6 +15,8 @@ import { theme } from './theme'; import { cumulativeSum } from './cumulative_sum'; import { derivative } from './derivative'; import { movingAverage } from './moving_average'; +import { mapColumn } from './map_column'; +import { math } from './math'; export const functionSpecs: AnyExpressionFunctionDefinition[] = [ clog, @@ -25,6 +27,8 @@ export const functionSpecs: AnyExpressionFunctionDefinition[] = [ cumulativeSum, derivative, movingAverage, + mapColumn, + math, ]; export * from './clog'; @@ -35,3 +39,5 @@ export * from './theme'; export * from './cumulative_sum'; export * from './derivative'; export * from './moving_average'; +export { mapColumn, MapColumnArguments } from './map_column'; +export { math, MathArguments, MathInput } from './math'; diff --git a/src/plugins/expressions/common/expression_functions/specs/map_column.ts b/src/plugins/expressions/common/expression_functions/specs/map_column.ts new file mode 100644 index 0000000000000..e2605e5ddf38d --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/map_column.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, getType } from '../../expression_types'; + +export interface MapColumnArguments { + id?: string | null; + name: string; + expression?: (datatable: Datatable) => Promise; + copyMetaFrom?: string | null; +} + +export const mapColumn: ExpressionFunctionDefinition< + 'mapColumn', + Datatable, + MapColumnArguments, + Promise +> = { + name: 'mapColumn', + aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. + type: 'datatable', + inputTypes: ['datatable'], + help: i18n.translate('expressions.functions.mapColumnHelpText', { + defaultMessage: + 'Adds a column calculated as the result of other columns. ' + + 'Changes are made only when you provide arguments.' + + 'See also {alterColumnFn} and {staticColumnFn}.', + values: { + alterColumnFn: '`alterColumn`', + staticColumnFn: '`staticColumn`', + }, + }), + args: { + id: { + types: ['string', 'null'], + help: i18n.translate('expressions.functions.mapColumn.args.idHelpText', { + defaultMessage: + 'An optional id of the resulting column. When `null` the name/column argument is used as id.', + }), + required: false, + default: null, + }, + name: { + types: ['string'], + aliases: ['_', 'column'], + help: i18n.translate('expressions.functions.mapColumn.args.nameHelpText', { + defaultMessage: 'The name of the resulting column.', + }), + required: true, + }, + expression: { + types: ['boolean', 'number', 'string', 'null'], + resolve: false, + aliases: ['exp', 'fn', 'function'], + help: i18n.translate('expressions.functions.mapColumn.args.expressionHelpText', { + defaultMessage: + 'An expression that is executed on every row, provided with a single-row {DATATABLE} context and returning the cell value.', + values: { + DATATABLE: '`datatable`', + }, + }), + required: true, + }, + copyMetaFrom: { + types: ['string', 'null'], + help: i18n.translate('expressions.functions.mapColumn.args.copyMetaFromHelpText', { + defaultMessage: + "If set, the meta object from the specified column id is copied over to the specified target column. If the column doesn't exist it silently fails.", + }), + required: false, + default: null, + }, + }, + fn: (input, args) => { + const expression = args.expression || (() => Promise.resolve(null)); + const columnId = args.id != null ? args.id : args.name; + + const columns = [...input.columns]; + const rowPromises = input.rows.map((row) => { + return expression({ + type: 'datatable', + columns, + rows: [row], + }).then((val) => ({ + ...row, + [columnId]: val, + })); + }); + + return Promise.all(rowPromises).then((rows) => { + const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); + const type = rows.length ? getType(rows[0][columnId]) : 'null'; + const newColumn = { + id: columnId, + name: args.name, + meta: { type }, + }; + if (args.copyMetaFrom) { + const metaSourceFrom = columns.find(({ id }) => id === args.copyMetaFrom); + newColumn.meta = { ...newColumn.meta, ...(metaSourceFrom?.meta || {}) }; + } + + if (existingColumnIndex === -1) { + columns.push(newColumn); + } else { + columns[existingColumnIndex] = newColumn; + } + + return { + type: 'datatable', + columns, + rows, + } as Datatable; + }); + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/math.ts b/src/plugins/expressions/common/expression_functions/specs/math.ts new file mode 100644 index 0000000000000..a70c032769b57 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/math.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { map, zipObject } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { evaluate } from '@kbn/tinymath'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, isDatatable } from '../../expression_types'; + +export type MathArguments = { + expression: string; + onError?: 'null' | 'zero' | 'false' | 'throw'; +}; + +export type MathInput = number | Datatable; + +const TINYMATH = '`TinyMath`'; +const TINYMATH_URL = + 'https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html'; + +const isString = (val: any): boolean => typeof val === 'string'; + +function pivotObjectArray< + RowType extends { [key: string]: any }, + ReturnColumns extends string | number | symbol = keyof RowType +>(rows: RowType[], columns?: string[]): Record { + const columnNames = columns || Object.keys(rows[0]); + if (!columnNames.every(isString)) { + throw new Error('Columns should be an array of strings'); + } + + const columnValues = map(columnNames, (name) => map(rows, name)); + return zipObject(columnNames, columnValues); +} + +export const errors = { + emptyExpression: () => + new Error( + i18n.translate('expressions.functions.math.emptyExpressionErrorMessage', { + defaultMessage: 'Empty expression', + }) + ), + tooManyResults: () => + new Error( + i18n.translate('expressions.functions.math.tooManyResultsErrorMessage', { + defaultMessage: + 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', + values: { + mean: 'mean()', + sum: 'sum()', + }, + }) + ), + executionFailed: () => + new Error( + i18n.translate('expressions.functions.math.executionFailedErrorMessage', { + defaultMessage: 'Failed to execute math expression. Check your column names', + }) + ), + emptyDatatable: () => + new Error( + i18n.translate('expressions.functions.math.emptyDatatableErrorMessage', { + defaultMessage: 'Empty datatable', + }) + ), +}; + +const fallbackValue = { + null: null, + zero: 0, + false: false, +} as const; + +export const math: ExpressionFunctionDefinition< + 'math', + MathInput, + MathArguments, + boolean | number | null +> = { + name: 'math', + type: undefined, + inputTypes: ['number', 'datatable'], + help: i18n.translate('expressions.functions.mathHelpText', { + defaultMessage: + 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + + 'The {DATATABLE} columns are available by their column name. ' + + 'If the {CONTEXT} is a number it is available as {value}.', + values: { + TINYMATH, + CONTEXT: '_context_', + DATATABLE: '`datatable`', + value: '`value`', + TYPE_NUMBER: '`number`', + }, + }), + args: { + expression: { + aliases: ['_'], + types: ['string'], + help: i18n.translate('expressions.functions.math.args.expressionHelpText', { + defaultMessage: 'An evaluated {TINYMATH} expression. See {TINYMATH_URL}.', + values: { + TINYMATH, + TINYMATH_URL, + }, + }), + }, + onError: { + types: ['string'], + options: ['throw', 'false', 'zero', 'null'], + help: i18n.translate('expressions.functions.math.args.onErrorHelpText', { + defaultMessage: + "In case the {TINYMATH} evaluation fails or returns NaN, the return value is specified by onError. When `'throw'`, it will throw an exception, terminating expression execution (default).", + values: { + TINYMATH, + }, + }), + }, + }, + fn: (input, args) => { + const { expression, onError } = args; + const onErrorValue = onError ?? 'throw'; + + if (!expression || expression.trim() === '') { + throw errors.emptyExpression(); + } + + const mathContext = isDatatable(input) + ? pivotObjectArray( + input.rows, + input.columns.map((col) => col.name) + ) + : { value: input }; + + try { + const result = evaluate(expression, mathContext); + if (Array.isArray(result)) { + if (result.length === 1) { + return result[0]; + } + throw errors.tooManyResults(); + } + if (isNaN(result)) { + // make TS happy + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + throw errors.executionFailed(); + } + return result; + } catch (e) { + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + if (isDatatable(input) && input.rows.length === 0) { + throw errors.emptyDatatable(); + } else { + throw e; + } + } + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts new file mode 100644 index 0000000000000..6b0dce4ff9a2a --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../../expression_types'; +import { mapColumn, MapColumnArguments } from '../map_column'; +import { emptyTable, functionWrapper, testTable } from './utils'; + +const pricePlusTwo = (datatable: Datatable) => Promise.resolve(datatable.rows[0].price + 2); + +describe('mapColumn', () => { + const fn = functionWrapper(mapColumn); + const runFn = (input: Datatable, args: MapColumnArguments) => + fn(input, args) as Promise; + + it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { + return runFn(testTable, { + id: 'pricePlusTwo', + name: 'pricePlusTwo', + expression: pricePlusTwo, + }).then((result) => { + const arbitraryRowIndex = 2; + + expect(result.type).toBe('datatable'); + expect(result.columns).toEqual([ + ...testTable.columns, + { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, + ]); + expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); + }); + }); + + it('overwrites existing column with the new column if an existing column name is provided', () => { + return runFn(testTable, { name: 'name', expression: pricePlusTwo }).then((result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + const arbitraryRowIndex = 4; + + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(testTable.columns.length); + expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); + expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); + }); + }); + + it('adds a column to empty tables', () => { + return runFn(emptyTable, { name: 'name', expression: pricePlusTwo }).then((result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + }); + }); + + it('should assign specific id, different from name, when id arg is passed for new columns', () => { + return runFn(emptyTable, { name: 'name', id: 'myid', expression: pricePlusTwo }).then( + (result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0]).toHaveProperty('id', 'myid'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + } + ); + }); + + it('should assign specific id, different from name, when id arg is passed for copied column', () => { + return runFn(testTable, { name: 'name', id: 'myid', expression: pricePlusTwo }).then( + (result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + expect(result.type).toBe('datatable'); + expect(result.columns[nameColumnIndex]).toEqual({ + id: 'myid', + name: 'name', + meta: { type: 'number' }, + }); + } + ); + }); + + it('should copy over the meta information from the specified column', () => { + return runFn( + { + ...testTable, + columns: [ + ...testTable.columns, + // add a new entry + { + id: 'myId', + name: 'myName', + meta: { type: 'date', params: { id: 'number', params: { digits: 2 } } }, + }, + ], + rows: testTable.rows.map((row) => ({ ...row, myId: Date.now() })), + }, + { name: 'name', copyMetaFrom: 'myId', expression: pricePlusTwo } + ).then((result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + expect(result.type).toBe('datatable'); + expect(result.columns[nameColumnIndex]).toEqual({ + id: 'name', + name: 'name', + meta: { type: 'date', params: { id: 'number', params: { digits: 2 } } }, + }); + }); + }); + + it('should be resilient if the references column for meta information does not exists', () => { + return runFn(emptyTable, { name: 'name', copyMetaFrom: 'time', expression: pricePlusTwo }).then( + (result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0]).toHaveProperty('id', 'name'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + } + ); + }); + + it('should correctly infer the type fromt he first row if the references column for meta information does not exists', () => { + return runFn( + { ...emptyTable, rows: [...emptyTable.rows, { value: 5 }] }, + { name: 'value', copyMetaFrom: 'time', expression: pricePlusTwo } + ).then((result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'value'); + expect(result.columns[0]).toHaveProperty('id', 'value'); + expect(result.columns[0].meta).toHaveProperty('type', 'number'); + }); + }); + + describe('expression', () => { + it('maps null values to the new column', () => { + return runFn(testTable, { name: 'empty' }).then((result) => { + const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); + const arbitraryRowIndex = 8; + + expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); + expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts similarity index 63% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js rename to src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts index f5b8123ab8568..7541852cdbdaf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js +++ b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts @@ -1,19 +1,16 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { getFunctionErrors } from '../../../i18n'; -import { emptyTable, testTable } from './__fixtures__/test_tables'; -import { math } from './math'; - -const errors = getFunctionErrors().math; +import { errors, math } from '../math'; +import { emptyTable, functionWrapper, testTable } from './utils'; describe('math', () => { - const fn = functionWrapper(math); + const fn = functionWrapper(math); it('evaluates math expressions without reference to context', () => { expect(fn(null, { expression: '10.5345' })).toBe(10.5345); @@ -48,6 +45,19 @@ describe('math', () => { expect(fn(testTable, { expression: 'count(name)' })).toBe(9); }); }); + + describe('onError', () => { + it('should return the desired fallback value, for invalid expressions', () => { + expect(fn(testTable, { expression: 'mean(name)', onError: 'zero' })).toBe(0); + expect(fn(testTable, { expression: 'mean(name)', onError: 'null' })).toBe(null); + expect(fn(testTable, { expression: 'mean(name)', onError: 'false' })).toBe(false); + }); + it('should return the desired fallback value, for division by zero', () => { + expect(fn(testTable, { expression: '1/0', onError: 'zero' })).toBe(0); + expect(fn(testTable, { expression: '1/0', onError: 'null' })).toBe(null); + expect(fn(testTable, { expression: '1/0', onError: 'false' })).toBe(false); + }); + }); }); describe('invalid expressions', () => { @@ -88,5 +98,23 @@ describe('math', () => { new RegExp(errors.emptyDatatable().message) ); }); + + it('should not throw when requesting fallback values for invalid expression', () => { + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'zero' })).not.toThrow(); + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'false' })).not.toThrow(); + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'null' })).not.toThrow(); + }); + + it('should throw when declared in the onError argument', () => { + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'throw' })).toThrow( + new RegExp(errors.executionFailed().message) + ); + }); + + it('should throw when dividing by zero', () => { + expect(() => fn(testTable, { expression: '1/0', onError: 'throw' })).toThrow( + new RegExp('Cannot divide by 0') + ); + }); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts b/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts index 9006de9067616..7369570cf2c4b 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts @@ -9,16 +9,219 @@ import { mapValues } from 'lodash'; import { AnyExpressionFunctionDefinition } from '../../types'; import { ExecutionContext } from '../../../execution/types'; +import { Datatable } from '../../../expression_types'; /** * Takes a function spec and passes in default args, * overriding with any provided args. */ -export const functionWrapper = (spec: AnyExpressionFunctionDefinition) => { +export const functionWrapper = ( + spec: AnyExpressionFunctionDefinition +) => { const defaultArgs = mapValues(spec.args, (argSpec) => argSpec.default); return ( - context: object | null, + context: ContextType, args: Record = {}, handlers: ExecutionContext = {} as ExecutionContext ) => spec.fn(context, { ...defaultArgs, ...args }, handlers); }; + +const emptyTable: Datatable = { + type: 'datatable', + columns: [], + rows: [], +}; + +const testTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + { + id: 'time', + name: 'time', + meta: { type: 'date' }, + }, + { + id: 'price', + name: 'price', + meta: { type: 'number' }, + }, + { + id: 'quantity', + name: 'quantity', + meta: { type: 'number' }, + }, + { + id: 'in_stock', + name: 'in_stock', + meta: { type: 'boolean' }, + }, + ], + rows: [ + { + name: 'product1', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 605, + quantity: 100, + in_stock: true, + }, + { + name: 'product1', + time: 1517929200950, // 06 Feb 2018 15:00:00 GMT + price: 583, + quantity: 200, + in_stock: true, + }, + { + name: 'product1', + time: 1518015600950, // 07 Feb 2018 15:00:00 GMT + price: 420, + quantity: 300, + in_stock: true, + }, + { + name: 'product2', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 216, + quantity: 350, + in_stock: false, + }, + { + name: 'product2', + time: 1517929200950, // 06 Feb 2018 15:00:00 GMT + price: 200, + quantity: 256, + in_stock: false, + }, + { + name: 'product2', + time: 1518015600950, // 07 Feb 2018 15:00:00 GMT + price: 190, + quantity: 231, + in_stock: false, + }, + { + name: 'product3', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 67, + quantity: 240, + in_stock: true, + }, + { + name: 'product4', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 311, + quantity: 447, + in_stock: false, + }, + { + name: 'product5', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 288, + quantity: 384, + in_stock: true, + }, + ], +}; + +const stringTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + { + id: 'time', + name: 'time', + meta: { type: 'string' }, + }, + { + id: 'price', + name: 'price', + meta: { type: 'string' }, + }, + { + id: 'quantity', + name: 'quantity', + meta: { type: 'string' }, + }, + { + id: 'in_stock', + name: 'in_stock', + meta: { type: 'string' }, + }, + ], + rows: [ + { + name: 'product1', + time: '2018-02-05T15:00:00.950Z', + price: '605', + quantity: '100', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-06T15:00:00.950Z', + price: '583', + quantity: '200', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-07T15:00:00.950Z', + price: '420', + quantity: '300', + in_stock: 'true', + }, + { + name: 'product2', + time: '2018-02-05T15:00:00.950Z', + price: '216', + quantity: '350', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-06T15:00:00.950Z', + price: '200', + quantity: '256', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-07T15:00:00.950Z', + price: '190', + quantity: '231', + in_stock: 'false', + }, + { + name: 'product3', + time: '2018-02-05T15:00:00.950Z', + price: '67', + quantity: '240', + in_stock: 'true', + }, + { + name: 'product4', + time: '2018-02-05T15:00:00.950Z', + price: '311', + quantity: '447', + in_stock: 'false', + }, + { + name: 'product5', + time: '2018-02-05T15:00:00.950Z', + price: '288', + quantity: '384', + in_stock: 'true', + }, + ], +}; + +export { emptyTable, testTable, stringTable }; diff --git a/src/plugins/vis_type_xy/server/plugin.ts b/src/plugins/vis_type_xy/server/plugin.ts index fd670e288ff5b..a9e6020cf3ee8 100644 --- a/src/plugins/vis_type_xy/server/plugin.ts +++ b/src/plugins/vis_type_xy/server/plugin.ts @@ -20,6 +20,7 @@ export const uiSettingsConfig: Record> = { name: i18n.translate('visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name', { defaultMessage: 'Legacy charts library', }), + requiresPageReload: true, value: false, description: i18n.translate( 'visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description', diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index a7a5b8626914a..349e024f31c31 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -9,7 +9,9 @@ import { i18n } from '@kbn/i18n'; import { SavedObjectMetaData, OnSaveProps } from 'src/plugins/saved_objects/public'; import { first } from 'rxjs/operators'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { SavedObjectAttributes } from '../../../../core/public'; +import { extractSearchSourceReferences } from '../../../data/public'; import { EmbeddableFactoryDefinition, EmbeddableOutput, @@ -236,4 +238,42 @@ export class VisualizeEmbeddableFactory } ); } + + public extract(_state: EmbeddableStateWithType) { + const state = (_state as unknown) as VisualizeInput; + const references = []; + + if (state.savedVis?.data.searchSource) { + const [, searchSourceReferences] = extractSearchSourceReferences( + state.savedVis.data.searchSource + ); + + references.push(...searchSourceReferences); + } + + if (state.savedVis?.data.savedSearchId) { + references.push({ + name: 'search_0', + type: 'search', + id: String(state.savedVis.data.savedSearchId), + }); + } + + if (state.savedVis?.params.controls) { + const controls = state.savedVis.params.controls; + controls.forEach((control: Record, i: number) => { + if (!control.indexPattern) { + return; + } + control.indexPatternRefName = `control_${i}_index_pattern`; + references.push({ + name: control.indexPatternRefName, + type: 'index-pattern', + id: control.indexPattern, + }); + }); + } + + return { state: _state, references }; + } } diff --git a/test/functional/apps/discover/_discover_histogram.ts b/test/functional/apps/discover/_discover_histogram.ts index 9a6692dc793d6..2a6096f8d1a78 100644 --- a/test/functional/apps/discover/_discover_histogram.ts +++ b/test/functional/apps/discover/_discover_histogram.ts @@ -97,15 +97,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(false); await PageObjects.discover.saveSearch(savedSearch); await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.clickNewSearchButton(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.loadSavedSearch('persisted hidden histogram'); + await PageObjects.header.waitUntilLoadingHasFinished(); canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); await testSubjects.click('discoverChartToggle'); canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); - await PageObjects.discover.clickResetSavedSearchButton(); + await PageObjects.discover.saveSearch('persisted hidden histogram'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.clickNewSearchButton(); + await PageObjects.discover.loadSavedSearch('persisted hidden histogram'); await PageObjects.header.waitUntilLoadingHasFinished(); canvasExists = await elasticChart.canvasExists(); - expect(canvasExists).to.be(false); + expect(canvasExists).to.be(true); }); }); } diff --git a/tsconfig.base.json b/tsconfig.base.json index c63d43b4cb6ad..865806cffe5bb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -39,7 +39,7 @@ // "resolveJsonModule" allows for importing, extracting types from and generating .json files. "resolveJsonModule": true, // Disallow inconsistently-cased references to the same file. - "forceConsistentCasingInFileNames": true, + "forceConsistentCasingInFileNames": false, // Forbid unused local variables as the rule was deprecated by ts-lint "noUnusedLocals": true, // Provide full support for iterables in for..of, spread and destructuring when targeting ES5 or ES3. diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index aab848d4555d2..83a1ff952cb5d 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -17,6 +17,9 @@ Table of Contents - [Alert types](#alert-types) - [Methods](#methods) - [Executor](#executor) + - [Licensing](#licensing) + - [Documentation](#documentation) + - [Tests](#tests) - [Example](#example) - [Role Based Access-Control](#role-based-access-control) - [Alert Navigation](#alert-navigation) @@ -124,6 +127,19 @@ For example, if the `context` has one variable `foo` which is an object that has } ``` +## Licensing + +Currently most of the alerts are free features. But some alert types are subscription features, such as the tracking containment alert. + +## Documentation + +You should create documentation for the new alert type. Make an entry in the alert type index [`docs/user/alerting/alert-types.asciidoc`](../../../docs/user/alerting/alert-types.asciidoc) that points to a new document for the alert type that should be in the proper application directory. + +## Tests + +The alert type should have jest tests and optionaly functional tests. +In the the tests we recomend to test the expected alert execution result with a different input params, the structure of the created alert and the params validation. The rest will be guaranteed as a framework functionality. + ### Example This example receives server and threshold as parameters. It will read the CPU usage of the server and schedule actions to be executed (asynchronously by the task manager) if the reading is greater than the threshold. diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 048bc3468f149..5c4d1d55cff04 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -34,8 +34,6 @@ import { joinRows } from './join_rows'; import { lt } from './lt'; import { lte } from './lte'; import { mapCenter } from './map_center'; -import { mapColumn } from './mapColumn'; -import { math } from './math'; import { metric } from './metric'; import { neq } from './neq'; import { ply } from './ply'; @@ -89,8 +87,6 @@ export const functions = [ lte, joinRows, mapCenter, - mapColumn, - math, metric, neq, ply, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js deleted file mode 100644 index d511c7774122d..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { testTable, emptyTable } from './__fixtures__/test_tables'; -import { mapColumn } from './mapColumn'; - -const pricePlusTwo = (datatable) => Promise.resolve(datatable.rows[0].price + 2); - -describe('mapColumn', () => { - const fn = functionWrapper(mapColumn); - - it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { - return fn(testTable, { - id: 'pricePlusTwo', - name: 'pricePlusTwo', - expression: pricePlusTwo, - }).then((result) => { - const arbitraryRowIndex = 2; - - expect(result.type).toBe('datatable'); - expect(result.columns).toEqual([ - ...testTable.columns, - { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, - ]); - expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); - }); - }); - - it('overwrites existing column with the new column if an existing column name is provided', () => { - return fn(testTable, { name: 'name', expression: pricePlusTwo }).then((result) => { - const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); - const arbitraryRowIndex = 4; - - expect(result.type).toBe('datatable'); - expect(result.columns).toHaveLength(testTable.columns.length); - expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); - expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); - }); - }); - - it('adds a column to empty tables', () => { - return fn(emptyTable, { name: 'name', expression: pricePlusTwo }).then((result) => { - expect(result.type).toBe('datatable'); - expect(result.columns).toHaveLength(1); - expect(result.columns[0]).toHaveProperty('name', 'name'); - expect(result.columns[0].meta).toHaveProperty('type', 'null'); - }); - }); - - describe('expression', () => { - it('maps null values to the new column', () => { - return fn(testTable, { name: 'empty' }).then((result) => { - const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); - const arbitraryRowIndex = 8; - - expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); - expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); - }); - }); - }); -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts deleted file mode 100644 index 63cc0d6cbc687..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Datatable, ExpressionFunctionDefinition, getType } from '../../../types'; -import { getFunctionHelp } from '../../../i18n'; - -interface Arguments { - name: string; - expression: (datatable: Datatable) => Promise; -} - -export function mapColumn(): ExpressionFunctionDefinition< - 'mapColumn', - Datatable, - Arguments, - Promise -> { - const { help, args: argHelp } = getFunctionHelp().mapColumn; - - return { - name: 'mapColumn', - aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. - type: 'datatable', - inputTypes: ['datatable'], - help, - args: { - name: { - types: ['string'], - aliases: ['_', 'column'], - help: argHelp.name, - required: true, - }, - expression: { - types: ['boolean', 'number', 'string', 'null'], - resolve: false, - aliases: ['exp', 'fn', 'function'], - help: argHelp.expression, - required: true, - }, - }, - fn: (input, args) => { - const expression = args.expression || (() => Promise.resolve(null)); - - const columns = [...input.columns]; - const rowPromises = input.rows.map((row) => { - return expression({ - type: 'datatable', - columns, - rows: [row], - }).then((val) => ({ - ...row, - [args.name]: val, - })); - }); - - return Promise.all(rowPromises).then((rows) => { - const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); - const type = rows.length ? getType(rows[0][args.name]) : 'null'; - const newColumn = { id: args.name, name: args.name, meta: { type } }; - - if (existingColumnIndex === -1) { - columns.push(newColumn); - } else { - columns[existingColumnIndex] = newColumn; - } - - return { - type: 'datatable', - columns, - rows, - } as Datatable; - }); - }, - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts deleted file mode 100644 index af70fa729b7da..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { evaluate } from '@kbn/tinymath'; -import { pivotObjectArray } from '../../../common/lib/pivot_object_array'; -import { Datatable, isDatatable, ExpressionFunctionDefinition } from '../../../types'; -import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; - -interface Arguments { - expression: string; -} - -type Input = number | Datatable; - -export function math(): ExpressionFunctionDefinition<'math', Input, Arguments, number> { - const { help, args: argHelp } = getFunctionHelp().math; - const errors = getFunctionErrors().math; - - return { - name: 'math', - type: 'number', - inputTypes: ['number', 'datatable'], - help, - args: { - expression: { - aliases: ['_'], - types: ['string'], - help: argHelp.expression, - }, - }, - fn: (input, args) => { - const { expression } = args; - - if (!expression || expression.trim() === '') { - throw errors.emptyExpression(); - } - - const mathContext = isDatatable(input) - ? pivotObjectArray( - input.rows, - input.columns.map((col) => col.name) - ) - : { value: input }; - - try { - const result = evaluate(expression, mathContext); - if (Array.isArray(result)) { - if (result.length === 1) { - return result[0]; - } - throw errors.tooManyResults(); - } - if (isNaN(result)) { - throw errors.executionFailed(); - } - return result; - } catch (e) { - if (isDatatable(input) && input.rows.length === 0) { - throw errors.emptyDatatable(); - } else { - throw e; - } - } - }, - }; -} diff --git a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts b/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts deleted file mode 100644 index f8d0311d08961..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { mapColumn } from '../../../canvas_plugin_src/functions/common/mapColumn'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { CANVAS, DATATABLE } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.mapColumnHelpText', { - defaultMessage: - 'Adds a column calculated as the result of other columns. ' + - 'Changes are made only when you provide arguments.' + - 'See also {alterColumnFn} and {staticColumnFn}.', - values: { - alterColumnFn: '`alterColumn`', - staticColumnFn: '`staticColumn`', - }, - }), - args: { - name: i18n.translate('xpack.canvas.functions.mapColumn.args.nameHelpText', { - defaultMessage: 'The name of the resulting column.', - }), - expression: i18n.translate('xpack.canvas.functions.mapColumn.args.expressionHelpText', { - defaultMessage: - 'A {CANVAS} expression that is passed to each row as a single row {DATATABLE}.', - values: { - CANVAS, - DATATABLE, - }, - }), - }, -}; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/math.ts b/x-pack/plugins/canvas/i18n/functions/dict/math.ts deleted file mode 100644 index 110136872e1ff..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/math.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { math } from '../../../canvas_plugin_src/functions/common/math'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { DATATABLE, CONTEXT, TINYMATH, TINYMATH_URL, TYPE_NUMBER } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.mathHelpText', { - defaultMessage: - 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + - 'The {DATATABLE} columns are available by their column name. ' + - 'If the {CONTEXT} is a number it is available as {value}.', - values: { - TINYMATH, - CONTEXT, - DATATABLE, - value: '`value`', - TYPE_NUMBER, - }, - }), - args: { - expression: i18n.translate('xpack.canvas.functions.math.args.expressionHelpText', { - defaultMessage: 'An evaluated {TINYMATH} expression. See {TINYMATH_URL}.', - values: { - TINYMATH, - TINYMATH_URL, - }, - }), - }, -}; - -export const errors = { - emptyExpression: () => - new Error( - i18n.translate('xpack.canvas.functions.math.emptyExpressionErrorMessage', { - defaultMessage: 'Empty expression', - }) - ), - tooManyResults: () => - new Error( - i18n.translate('xpack.canvas.functions.math.tooManyResultsErrorMessage', { - defaultMessage: - 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', - values: { - mean: 'mean()', - sum: 'sum()', - }, - }) - ), - executionFailed: () => - new Error( - i18n.translate('xpack.canvas.functions.math.executionFailedErrorMessage', { - defaultMessage: 'Failed to execute math expression. Check your column names', - }) - ), - emptyDatatable: () => - new Error( - i18n.translate('xpack.canvas.functions.math.emptyDatatableErrorMessage', { - defaultMessage: 'Empty datatable', - }) - ), -}; diff --git a/x-pack/plugins/canvas/i18n/functions/function_errors.ts b/x-pack/plugins/canvas/i18n/functions/function_errors.ts index ac86eb4c4d0e9..4a85018c1b4ac 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_errors.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_errors.ts @@ -16,7 +16,6 @@ import { errors as demodata } from './dict/demodata'; import { errors as getCell } from './dict/get_cell'; import { errors as image } from './dict/image'; import { errors as joinRows } from './dict/join_rows'; -import { errors as math } from './dict/math'; import { errors as ply } from './dict/ply'; import { errors as pointseries } from './dict/pointseries'; import { errors as progress } from './dict/progress'; @@ -36,7 +35,6 @@ export const getFunctionErrors = () => ({ getCell, image, joinRows, - math, ply, pointseries, progress, diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index fd0d3ed952b2f..f0585b226d33b 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -46,9 +46,7 @@ import { help as location } from './dict/location'; import { help as lt } from './dict/lt'; import { help as lte } from './dict/lte'; import { help as mapCenter } from './dict/map_center'; -import { help as mapColumn } from './dict/map_column'; import { help as markdown } from './dict/markdown'; -import { help as math } from './dict/math'; import { help as metric } from './dict/metric'; import { help as neq } from './dict/neq'; import { help as pie } from './dict/pie'; @@ -209,9 +207,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ lt, lte, mapCenter, - mapColumn, markdown, - math, metric, neq, pie, diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts index 809c4ad1ea1bd..490519187f49e 100644 --- a/x-pack/plugins/case/server/client/cases/mock.ts +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -11,6 +11,7 @@ import { ConnectorMappingsAttributes, CaseUserActionsResponse, AssociationType, + CommentResponseAlertsType, } from '../../../common/api'; import { BasicParams } from './types'; @@ -76,6 +77,20 @@ export const commentAlert: CommentResponse = { version: 'WzEsMV0=', }; +export const commentAlertMultipleIds: CommentResponseAlertsType = { + ...commentAlert, + id: 'mock-comment-2', + alertId: ['alert-id-1', 'alert-id-2'], + index: 'alert-index-1', + type: CommentType.alert as const, +}; + +export const commentGeneratedAlert: CommentResponseAlertsType = { + ...commentAlertMultipleIds, + id: 'mock-comment-3', + type: CommentType.generatedAlert as const, +}; + export const defaultPipes = ['informationCreated']; export const basicParams: BasicParams = { description: 'a description', diff --git a/x-pack/plugins/case/server/client/cases/types.ts b/x-pack/plugins/case/server/client/cases/types.ts index f1d56e7132bd1..2dd2caf9fe73a 100644 --- a/x-pack/plugins/case/server/client/cases/types.ts +++ b/x-pack/plugins/case/server/client/cases/types.ts @@ -72,7 +72,7 @@ export interface TransformFieldsArgs { export interface ExternalServiceComment { comment: string; - commentId: string; + commentId?: string; } export interface MapIncident { diff --git a/x-pack/plugins/case/server/client/cases/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts index 361d0fb561afd..44e7a682aa7ed 100644 --- a/x-pack/plugins/case/server/client/cases/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -17,6 +17,8 @@ import { basicParams, userActions, commentAlert, + commentAlertMultipleIds, + commentGeneratedAlert, } from './mock'; import { @@ -48,7 +50,7 @@ describe('utils', () => { { actionType: 'overwrite', key: 'short_description', - pipes: ['informationCreated'], + pipes: [], value: 'a title', }, { @@ -71,7 +73,7 @@ describe('utils', () => { { actionType: 'overwrite', key: 'short_description', - pipes: ['myTestPipe'], + pipes: [], value: 'a title', }, { @@ -98,7 +100,7 @@ describe('utils', () => { }); expect(res).toEqual({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + short_description: 'a title', description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', }); }); @@ -122,13 +124,13 @@ describe('utils', () => { }, fields, currentIncident: { - short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + short_description: 'first title', description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)', }, }); expect(res).toEqual({ - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', + short_description: 'a title', description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-15T08:34:53.450Z by Another User)', }); @@ -168,7 +170,7 @@ describe('utils', () => { }); expect(res).toEqual({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)', + short_description: 'a title', description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)', }); }); @@ -190,7 +192,7 @@ describe('utils', () => { }); expect(res).toEqual({ - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by anotherUser)', + short_description: 'a title', description: 'a description (updated at 2020-03-15T08:34:53.450Z by anotherUser)', }); }); @@ -448,8 +450,7 @@ describe('utils', () => { labels: ['defacement'], issueType: null, parent: null, - short_description: - 'Super Bad Security Issue (created at 2019-11-25T21:54:48.952Z by elastic)', + short_description: 'Super Bad Security Issue', description: 'This is a brand new case of a bad meanie defacing data (created at 2019-11-25T21:54:48.952Z by elastic)', externalId: null, @@ -504,7 +505,7 @@ describe('utils', () => { expect(res.comments).toEqual([]); }); - it('it creates comments of type alert correctly', async () => { + it('it adds the total alert comments correctly', async () => { const res = await createIncident({ actionsClient: actionsMock, theCase: { @@ -512,7 +513,9 @@ describe('utils', () => { comments: [ { ...commentObj, id: 'comment-user-1' }, { ...commentAlert, id: 'comment-alert-1' }, - { ...commentAlert, id: 'comment-alert-2' }, + { + ...commentAlertMultipleIds, + }, ], }, // Remove second push @@ -536,14 +539,36 @@ describe('utils', () => { commentId: 'comment-user-1', }, { - comment: - 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', - commentId: 'comment-alert-1', + comment: 'Elastic Security Alerts attached to the case: 3', }, + ]); + }); + + it('it removes alerts correctly', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase: { + ...theCase, + comments: [ + { ...commentObj, id: 'comment-user-1' }, + commentAlertMultipleIds, + commentGeneratedAlert, + ], + }, + userActions, + connector, + mappings, + alerts: [], + }); + + expect(res.comments).toEqual([ { comment: - 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', - commentId: 'comment-alert-2', + 'Wow, good luck catching that bad meanie! (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-user-1', + }, + { + comment: 'Elastic Security Alerts attached to the case: 4', }, ]); }); @@ -578,8 +603,7 @@ describe('utils', () => { description: 'fun description \r\nThis is a brand new case of a bad meanie defacing data (updated at 2019-11-25T21:54:48.952Z by elastic)', externalId: 'external-id', - short_description: - 'Super Bad Security Issue (updated at 2019-11-25T21:54:48.952Z by elastic)', + short_description: 'Super Bad Security Issue', }, comments: [], }); diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index fda4142bf77c7..a5013d9b93982 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -40,6 +40,15 @@ import { } from './types'; import { getAlertIds } from '../../routes/api/utils'; +interface CreateIncidentArgs { + actionsClient: ActionsClient; + theCase: CaseResponse; + userActions: CaseUserActionsResponse; + connector: ActionConnector; + mappings: ConnectorMappingsAttributes[]; + alerts: CaseClientGetAlertsResponse; +} + export const getLatestPushInfo = ( connectorId: string, userActions: CaseUserActionsResponse @@ -75,14 +84,13 @@ const getCommentContent = (comment: CommentResponse): string => { return ''; }; -interface CreateIncidentArgs { - actionsClient: ActionsClient; - theCase: CaseResponse; - userActions: CaseUserActionsResponse; - connector: ActionConnector; - mappings: ConnectorMappingsAttributes[]; - alerts: CaseClientGetAlertsResponse; -} +const countAlerts = (comments: CaseResponse['comments']): number => + comments?.reduce((total, comment) => { + if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { + return total + (Array.isArray(comment.alertId) ? comment.alertId.length : 1); + } + return total; + }, 0) ?? 0; export const createIncident = async ({ actionsClient, @@ -152,22 +160,34 @@ export const createIncident = async ({ userActions .slice(latestPushInfo?.index ?? 0) .filter( - (action, index) => - Array.isArray(action.action_field) && action.action_field[0] === 'comment' + (action) => Array.isArray(action.action_field) && action.action_field[0] === 'comment' ) .map((action) => action.comment_id) ); - const commentsToBeUpdated = caseComments?.filter((comment) => - commentsIdsToBeUpdated.has(comment.id) + + const commentsToBeUpdated = caseComments?.filter( + (comment) => + // We push only user's comments + comment.type === CommentType.user && commentsIdsToBeUpdated.has(comment.id) ); + const totalAlerts = countAlerts(caseComments); + let comments: ExternalServiceComment[] = []; + if (commentsToBeUpdated && Array.isArray(commentsToBeUpdated) && commentsToBeUpdated.length > 0) { const commentsMapping = mappings.find((m) => m.source === 'comments'); if (commentsMapping?.action_type !== 'nothing') { comments = transformComments(commentsToBeUpdated, ['informationAdded']); } } + + if (totalAlerts > 0) { + comments.push({ + comment: `Elastic Security Alerts attached to the case: ${totalAlerts}`, + }); + } + return { incident, comments }; }; @@ -247,7 +267,13 @@ export const prepareFieldsForTransformation = ({ key: mapping.target, value: params[mapping.source] ?? '', actionType: mapping.action_type, - pipes: mapping.action_type === 'append' ? [...defaultPipes, 'append'] : defaultPipes, + pipes: + // Do not transform titles + mapping.source !== 'title' + ? mapping.action_type === 'append' + ? [...defaultPipes, 'append'] + : defaultPipes + : [], }, ] : acc, diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts index bf398d1ffcf40..c8501130493ba 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts @@ -170,7 +170,7 @@ describe('Push case', () => { parent: null, priority: 'High', labels: ['LOLBins'], - summary: 'Another bad one (created at 2019-11-25T22:32:17.947Z by elastic)', + summary: 'Another bad one', description: 'Oh no, a bad meanie going LOLBins all over the place! (created at 2019-11-25T22:32:17.947Z by elastic)', externalId: null, diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md index eb7fbc9d590fa..9c806680f68a2 100644 --- a/x-pack/plugins/event_log/README.md +++ b/x-pack/plugins/event_log/README.md @@ -164,10 +164,12 @@ history records associated with specific saved object ids. ## API +Event Log plugin returns a service instance from setup() and client service from start() methods. + +### Setup ```typescript // IEvent is a TS type generated from the subset of ECS supported -// the NP plugin returns a service instance from setup() and start() export interface IEventLogService { registerProviderActions(provider: string, actions: string[]): void; isProviderActionRegistered(provider: string, action: string): boolean; @@ -237,6 +239,80 @@ properties `start`, `end`, and `duration` in the event. For example: It's anticipated that more "helper" methods like this will be provided in the future. +### Start +```typescript + +export interface IEventLogClientService { + getClient(request: KibanaRequest): IEventLogClient; +} + +export interface IEventLogClient { + findEventsBySavedObjectIds( + type: string, + ids: string[], + options?: Partial + ): Promise; +} +``` + +The plugin exposes an `IEventLogClientService` object to plugins that request it. +These plugins must call `getClient(request)` to get the event log client. + +## Experimental RESTful API + +Usage of the event log allows you to retrieve the events for a given saved object type by the specified set of IDs. +The following API is experimental and can change or be removed in a future release. + +### `GET /api/event_log/{type}/{id}/_find`: Get events for a given saved object type by the ID + +Collects event information from the event log for the selected saved object by type and ID. + +Params: + +|Property|Description|Type| +|---|---|---| +|type|The type of the saved object whose events you're trying to get.|string| +|id|The id of the saved object.|string| + +Query: + +|Property|Description|Type| +|---|---|---| +|page|The page number.|number| +|per_page|The number of events to return per page.|number| +|sort_field|Sorts the response. Could be an event fields returned in the response.|string| +|sort_order|Sort direction, either `asc` or `desc`.|string| +|filter|A KQL string that you filter with an attribute from the event. It should look like `event.action:(execute)`.|string| +|start|The date to start looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string| +|end|The date to stop looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string| + +### `POST /api/event_log/{type}/_find`: Retrive events for a given saved object type by the IDs + +Collects event information from the event log for the selected saved object by type and by IDs. + +Params: + +|Property|Description|Type| +|---|---|---| +|type|The type of the saved object whose events you're trying to get.|string| + +Query: + +|Property|Description|Type| +|---|---|---| +|page|The page number.|number| +|per_page|The number of events to return per page.|number| +|sort_field|Sorts the response. Could be an event field returned in the response.|string| +|sort_order|Sort direction, either `asc` or `desc`.|string| +|filter|A KQL string that you filter with an attribute from the event. It should look like `event.action:(execute)`.|string| +|start|The date to start looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string| +|end|The date to stop looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string| + +Body: + +|Property|Description|Type| +|---|---|---| +|ids|The array ids of the saved object.|string array| ## Stored data @@ -303,4 +379,3 @@ For more relevant information on ILM, see: [getting started with ILM doc]: https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-index-lifecycle-management.html [write index alias behavior]: https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-rollover-index.html#indices-rollover-is-write-index - diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index a9845c2315604..c61b431eed46d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -183,6 +183,13 @@ export const setup = async (arg?: { const enable = (phase: Phases) => createFormToggleAction(`enablePhaseSwitch-${phase}`); + const showDataAllocationOptions = (phase: Phases) => () => { + act(() => { + find(`${phase}-dataTierAllocationControls.dataTierSelect`).simulate('click'); + }); + component.update(); + }; + const createMinAgeActions = (phase: Phases) => { return { hasMinAgeInput: () => exists(`${phase}-selectedMinimumAge`), @@ -222,12 +229,10 @@ export const setup = async (arg?: { const createSearchableSnapshotActions = (phase: Phases) => { const fieldSelector = `searchableSnapshotField-${phase}`; const licenseCalloutSelector = `${fieldSelector}.searchableSnapshotDisabledDueToLicense`; - const rolloverCalloutSelector = `${fieldSelector}.searchableSnapshotFieldsNoRolloverCallout`; const toggleSelector = `${fieldSelector}.searchableSnapshotToggle`; const toggleSearchableSnapshot = createFormToggleAction(toggleSelector); return { - searchableSnapshotDisabledDueToRollover: () => exists(rolloverCalloutSelector), searchableSnapshotDisabled: () => exists(licenseCalloutSelector) && find(licenseCalloutSelector).props().disabled === true, searchableSnapshotsExists: () => exists(fieldSelector), @@ -379,6 +384,7 @@ export const setup = async (arg?: { }, warm: { enable: enable('warm'), + showDataAllocationOptions: showDataAllocationOptions('warm'), ...createMinAgeActions('warm'), setReplicas: setReplicas('warm'), hasErrorIndicator: () => exists('phaseErrorIndicator-warm'), @@ -390,6 +396,7 @@ export const setup = async (arg?: { }, cold: { enable: enable('cold'), + showDataAllocationOptions: showDataAllocationOptions('cold'), ...createMinAgeActions('cold'), setReplicas: setReplicas('cold'), setFreeze, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 7fe5c6f50d046..740aeebb852f1 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -405,6 +405,7 @@ describe('', () => { await actions.cold.setMinAgeUnits('s'); await actions.cold.setDataAllocation('node_attrs'); await actions.cold.setSelectedNodeAttribute('test:123'); + await actions.cold.setSearchableSnapshot('my-repo'); await actions.cold.setReplicas('123'); await actions.cold.setFreeze(true); await actions.cold.setIndexPriority('123'); @@ -426,6 +427,9 @@ describe('', () => { }, }, "freeze": Object {}, + "searchable_snapshot": Object { + "snapshot_repository": "my-repo", + }, "set_priority": Object { "priority": 123, }, @@ -445,19 +449,6 @@ describe('', () => { } `); }); - - // Setting searchable snapshot field disables setting replicas so we test this separately - test('setting searchable snapshot', async () => { - const { actions } = testBed; - await actions.cold.enable(true); - await actions.cold.setSearchableSnapshot('my-repo'); - await actions.savePolicy(); - const latestRequest2 = server.requests[server.requests.length - 1]; - const entirePolicy2 = JSON.parse(JSON.parse(latestRequest2.requestBody).body); - expect(entirePolicy2.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( - 'my-repo' - ); - }); }); }); @@ -693,43 +684,52 @@ describe('', () => { expect(find('cold-dataTierAllocationControls.dataTierSelect').text()).toContain('Off'); }); }); - }); - - describe('searchable snapshot', () => { describe('on cloud', () => { - describe('new policy', () => { + describe('using legacy data role config', () => { beforeEach(async () => { - // simulate creating a new policy - httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('')]); + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); httpRequestsMockHelpers.setListNodes({ - isUsingDeprecatedDataRoleConfig: false, nodesByAttributes: { test: ['123'] }, - nodesByRoles: { data: ['123'] }, + // On cloud, even if there are data_* roles set, the default, recommended allocation option should not + // be available. + nodesByRoles: { data_hot: ['123'] }, + isUsingDeprecatedDataRoleConfig: true, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); await act(async () => { - testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + testBed = await setup({ + appServicesContext: { + cloud: { + isCloudEnabled: true, + }, + license: licensingMock.createLicense({ license: { type: 'basic' } }), + }, + }); }); const { component } = testBed; component.update(); }); - test('defaults searchable snapshot to true on cloud', async () => { - const { find, actions } = testBed; - await actions.cold.enable(true); - expect( - find('searchableSnapshotField-cold.searchableSnapshotToggle').props()['aria-checked'] - ).toBe(true); + test('removes default, recommended option', async () => { + const { actions, find } = testBed; + await actions.warm.enable(true); + actions.warm.showDataAllocationOptions(); + + expect(find('defaultDataAllocationOption').exists()).toBeFalsy(); + expect(find('customDataAllocationOption').exists()).toBeTruthy(); + expect(find('noneDataAllocationOption').exists()).toBeTruthy(); + // Show the call-to-action for users to migrate their cluster to use node roles + expect(find('cloudDataTierCallout').exists()).toBeTruthy(); }); }); - describe('existing policy', () => { + describe('using node roles', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); httpRequestsMockHelpers.setListNodes({ - isUsingDeprecatedDataRoleConfig: false, nodesByAttributes: { test: ['123'] }, - nodesByRoles: { data: ['123'] }, + nodesByRoles: { data_hot: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); @@ -740,19 +740,34 @@ describe('', () => { const { component } = testBed; component.update(); }); - test('correctly sets snapshot repository default to "found-snapshots"', async () => { - const { actions } = testBed; + + test('should show recommended, custom and "off" options on cloud with data roles', async () => { + const { actions, find } = testBed; + + await actions.warm.enable(true); + actions.warm.showDataAllocationOptions(); + expect(find('defaultDataAllocationOption').exists()).toBeTruthy(); + expect(find('customDataAllocationOption').exists()).toBeTruthy(); + expect(find('noneDataAllocationOption').exists()).toBeTruthy(); + // We should not be showing the call-to-action for users to activate the cold tier on cloud + expect(find('cloudMissingColdTierCallout').exists()).toBeFalsy(); + // Do not show the call-to-action for users to migrate their cluster to use node roles + expect(find('cloudDataTierCallout').exists()).toBeFalsy(); + }); + + test('should show cloud notice when cold tier nodes do not exist', async () => { + const { actions, find } = testBed; await actions.cold.enable(true); - await actions.cold.toggleSearchableSnapshot(true); - await actions.savePolicy(); - const latestRequest = server.requests[server.requests.length - 1]; - const request = JSON.parse(JSON.parse(latestRequest.requestBody).body); - expect(request.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( - 'found-snapshots' - ); + expect(find('cloudMissingColdTierCallout').exists()).toBeTruthy(); + // Assert that other notices are not showing + expect(find('defaultAllocationNotice').exists()).toBeFalsy(); + expect(find('noNodeAttributesWarning').exists()).toBeFalsy(); }); }); }); + }); + + describe('searchable snapshot', () => { describe('on non-enterprise license', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); @@ -789,6 +804,64 @@ describe('', () => { expect(actions.cold.searchableSnapshotDisabledDueToLicense()).toBeTruthy(); }); }); + + describe('on cloud', () => { + describe('new policy', () => { + beforeEach(async () => { + // simulate creating a new policy + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('')]); + httpRequestsMockHelpers.setListNodes({ + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + + const { component } = testBed; + component.update(); + }); + test('defaults searchable snapshot to true on cloud', async () => { + const { find, actions } = testBed; + await actions.cold.enable(true); + expect( + find('searchableSnapshotField-cold.searchableSnapshotToggle').props()['aria-checked'] + ).toBe(true); + }); + }); + describe('existing policy', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + isUsingDeprecatedDataRoleConfig: false, + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data_hot: ['123'] }, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + + const { component } = testBed; + component.update(); + }); + test('correctly sets snapshot repository default to "found-snapshots"', async () => { + const { actions } = testBed; + await actions.cold.enable(true); + await actions.cold.toggleSearchableSnapshot(true); + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + const request = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(request.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( + 'found-snapshots' + ); + }); + }); + }); }); describe('with rollover', () => { beforeEach(async () => { @@ -844,14 +917,15 @@ describe('', () => { const { component } = testBed; component.update(); }); - test('hiding and disabling searchable snapshot field', async () => { + test('hides fields in hot phase', async () => { const { actions } = testBed; await actions.hot.toggleDefaultRollover(false); await actions.hot.toggleRollover(false); - await actions.cold.enable(true); + expect(actions.hot.forceMergeFieldExists()).toBeFalsy(); + expect(actions.hot.shrinkExists()).toBeFalsy(); expect(actions.hot.searchableSnapshotsExists()).toBeFalsy(); - expect(actions.cold.searchableSnapshotDisabledDueToRollover()).toBeTruthy(); + expect(actions.hot.readonlyExists()).toBeFalsy(); }); test('hiding rollover tip on minimum age', async () => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts index 113698fdf6df2..b02d190d10899 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts @@ -125,7 +125,7 @@ describe(' node allocation', () => { expect(actions.warm.hasDefaultAllocationWarning()).toBeTruthy(); }); - test('shows default allocation notice when hot tier exists, but not warm tier', async () => { + test('when configuring warm phase shows default allocation notice when hot tier exists, but not warm tier', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: {}, nodesByRoles: { data_hot: ['test'], data_cold: ['test'] }, @@ -309,7 +309,7 @@ describe(' node allocation', () => { describe('on cloud', () => { describe('with deprecated data role config', () => { - test('should hide data tier option on cloud using legacy node role configuration', async () => { + test('should hide data tier option on cloud', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: { test: ['123'] }, // On cloud, if using legacy config there will not be any "data_*" roles set. @@ -331,10 +331,29 @@ describe(' node allocation', () => { expect(exists('customDataAllocationOption')).toBeTruthy(); expect(exists('noneDataAllocationOption')).toBeTruthy(); }); + + test('should ask users to migrate to node roles when on cloud using legacy data role', async () => { + httpRequestsMockHelpers.setListNodes({ + nodesByAttributes: { test: ['123'] }, + // On cloud, if using legacy config there will not be any "data_*" roles set. + nodesByRoles: { data: ['test'] }, + isUsingDeprecatedDataRoleConfig: true, + }); + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + const { actions, component, exists } = testBed; + + component.update(); + await actions.warm.enable(true); + expect(component.find('.euiLoadingSpinner').exists()).toBeFalsy(); + + expect(exists('cloudDataTierCallout')).toBeTruthy(); + }); }); describe('with node role config', () => { - test('shows off, custom and data role options on cloud with data roles', async () => { + test('shows data role, custom and "off" options on cloud with data roles', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: { test: ['123'] }, nodesByRoles: { data: ['test'], data_hot: ['test'], data_warm: ['test'] }, @@ -372,7 +391,7 @@ describe(' node allocation', () => { await actions.cold.enable(true); expect(component.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(exists('cloudDataTierCallout')).toBeTruthy(); + expect(exists('cloudMissingColdTierCallout')).toBeTruthy(); // Assert that other notices are not showing expect(actions.cold.hasDefaultAllocationNotice()).toBeFalsy(); expect(actions.cold.hasNoNodeAttrsWarning()).toBeFalsy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index 27aacef1a368b..1dbc30674eaa5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -8,12 +8,9 @@ import React, { FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; import { EuiTextColor } from '@elastic/eui'; -import { useFormData } from '../../../../../../shared_imports'; - import { useConfigurationIssues } from '../../../form'; import { LearnMoreLink, ToggleFieldWithDescribedFormRow } from '../../'; @@ -36,23 +33,12 @@ const i18nTexts = { }, }; -const formFieldPaths = { - enabled: '_meta.cold.enabled', - searchableSnapshot: 'phases.cold.actions.searchable_snapshot.snapshot_repository', -}; - export const ColdPhase: FunctionComponent = () => { const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); - const [formData] = useFormData({ - watch: [formFieldPaths.searchableSnapshot], - }); - - const showReplicasField = get(formData, formFieldPaths.searchableSnapshot) == null; - return ( }> - {showReplicasField && } + {/* Freeze section */} {!isUsingSearchableSnapshotInHotPhase && ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx index 4d3dbbba39037..351d6ac1c530b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx @@ -7,21 +7,38 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; -import { EuiCallOut } from '@elastic/eui'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; const i18nTexts = { title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.title', { - defaultMessage: 'Create a cold tier', + defaultMessage: 'Migrate to data tiers', }), body: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.body', { - defaultMessage: 'Edit your Elastic Cloud deployment to set up a cold tier.', + defaultMessage: 'Migrate your Elastic Cloud deployment to use data tiers.', }), + linkText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.linkToCloudDeploymentDescription', + { defaultMessage: 'View cloud deployment' } + ), }; -export const CloudDataTierCallout: FunctionComponent = () => { +interface Props { + linkToCloudDeployment?: string; +} + +/** + * A call-to-action for users to migrate to data tiers if their cluster is still running + * the deprecated node.data:true config. + */ +export const CloudDataTierCallout: FunctionComponent = ({ linkToCloudDeployment }) => { return ( - {i18nTexts.body} + {i18nTexts.body}{' '} + {Boolean(linkToCloudDeployment) && ( + + {i18nTexts.linkText} + + )} ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx index 562267089051a..e43b750849774 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx @@ -11,8 +11,6 @@ import { EuiCallOut } from '@elastic/eui'; import { PhaseWithAllocation, DataTierRole } from '../../../../../../../../../common/types'; -import { AllocationNodeRole } from '../../../../../../../lib'; - const i18nTextsNodeRoleToDataTier: Record = { data_hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.dataTierHotLabel', { defaultMessage: 'hot', @@ -84,24 +82,13 @@ const i18nTexts = { interface Props { phase: PhaseWithAllocation; - targetNodeRole: AllocationNodeRole; + targetNodeRole: DataTierRole; } export const DefaultAllocationNotice: FunctionComponent = ({ phase, targetNodeRole }) => { - const content = - targetNodeRole === 'none' ? ( - - {i18nTexts.warning[phase].body} - - ) : ( - - {i18nTexts.notice[phase].body(targetNodeRole)} - - ); - - return content; + return ( + + {i18nTexts.notice[phase].body(targetNodeRole)} + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.tsx new file mode 100644 index 0000000000000..a194f3c07f900 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +import { PhaseWithAllocation } from '../../../../../../../../../common/types'; + +const i18nTexts = { + warning: { + warm: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the warm tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the warm or hot tier to use role-based allocation. The policy will fail to complete allocation if there are no available nodes.', + } + ), + }, + cold: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the cold tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the cold, warm, or hot tier to use role-based allocation. The policy will fail to complete allocation if there are no available nodes.', + } + ), + }, + }, +}; + +interface Props { + phase: PhaseWithAllocation; +} + +export const DefaultAllocationWarning: FunctionComponent = ({ phase }) => { + return ( + + {i18nTexts.warning[phase].body} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts index b3f57ac24e0d7..938e0a850f933 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts @@ -13,8 +13,12 @@ export { DataTierAllocation } from './data_tier_allocation'; export { DefaultAllocationNotice } from './default_allocation_notice'; +export { DefaultAllocationWarning } from './default_allocation_warning'; + export { NoNodeAttributesWarning } from './no_node_attributes_warning'; +export { MissingColdTierCallout } from './missing_cold_tier_callout'; + export { CloudDataTierCallout } from './cloud_data_tier_callout'; export { LoadingError } from './loading_error'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx new file mode 100644 index 0000000000000..21b8850e0b088 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.title', { + defaultMessage: 'Create a cold tier', + }), + body: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.body', { + defaultMessage: 'Edit your Elastic Cloud deployment to set up a cold tier.', + }), + linkText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.linkToCloudDeploymentDescription', + { defaultMessage: 'View cloud deployment' } + ), +}; + +interface Props { + linkToCloudDeployment?: string; +} + +/** + * A call-to-action for users to activate their cold tier slider to provision cold tier nodes. + * This may need to be change when we have autoscaling enabled on a cluster because nodes may not + * yet exist, but will automatically be provisioned. + */ +export const MissingColdTierCallout: FunctionComponent = ({ linkToCloudDeployment }) => { + return ( + + {i18nTexts.body}{' '} + {Boolean(linkToCloudDeployment) && ( + + {i18nTexts.linkText} + + )} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx index ad36039728f5c..7a660e0379a8d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx @@ -14,9 +14,7 @@ import { useKibana, useFormData } from '../../../../../../../shared_imports'; import { PhaseWithAllocation } from '../../../../../../../../common/types'; -import { getAvailableNodeRoleForPhase } from '../../../../../../lib/data_tiers'; - -import { isNodeRoleFirstPreference } from '../../../../../../lib'; +import { getAvailableNodeRoleForPhase, isNodeRoleFirstPreference } from '../../../../../../lib'; import { useLoadNodes } from '../../../../../../services/api'; @@ -25,7 +23,9 @@ import { DataTierAllocationType } from '../../../../types'; import { DataTierAllocation, DefaultAllocationNotice, + DefaultAllocationWarning, NoNodeAttributesWarning, + MissingColdTierCallout, CloudDataTierCallout, LoadingError, } from './components'; @@ -65,30 +65,48 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr ); const hasNodeAttrs = Boolean(Object.keys(nodesByAttributes ?? {}).length); const isCloudEnabled = cloud?.isCloudEnabled ?? false; + const cloudDeploymentUrl = cloud?.cloudDeploymentUrl; const renderNotice = () => { switch (allocationType) { case 'node_roles': - if (isCloudEnabled && phase === 'cold') { - const isUsingNodeRolesAllocation = !isUsingDeprecatedDataRoleConfig && hasDataNodeRoles; + /** + * We'll drive Cloud users to add a cold tier to their deployment if there are no nodes with the cold node role. + */ + if (isCloudEnabled && phase === 'cold' && !isUsingDeprecatedDataRoleConfig) { const hasNoNodesWithNodeRole = !nodesByRoles.data_cold?.length; - if (isUsingNodeRolesAllocation && hasNoNodesWithNodeRole) { + if (hasDataNodeRoles && hasNoNodesWithNodeRole) { // Tell cloud users they can deploy nodes on cloud. return ( <> - + ); } } + /** + * Node role allocation moves data in a phase to a corresponding tier of the same name. To prevent policy execution from getting + * stuck ILM allocation will fall back to a previous tier if possible. We show the WARNING below to inform a user when even + * this fallback will not succeed. + */ const allocationNodeRole = getAvailableNodeRoleForPhase(phase, nodesByRoles); - if ( - allocationNodeRole === 'none' || - !isNodeRoleFirstPreference(phase, allocationNodeRole) - ) { + if (allocationNodeRole === 'none') { + return ( + <> + + + + ); + } + + /** + * If we are able to fallback to a data tier that does not map to this phase, we show a notice informing the user that their + * data will not be assigned to a corresponding tier. + */ + if (!isNodeRoleFirstPreference(phase, allocationNodeRole)) { return ( <> @@ -106,6 +124,19 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr ); } + /** + * Special cloud case: when deprecated data role configuration is in use, it means that this deployment is not using + * the new node role based allocation. We drive users to the cloud console to migrate to node role based allocation + * in that case. + */ + if (isCloudEnabled && isUsingDeprecatedDataRoleConfig) { + return ( + <> + + + + ); + } break; default: return null; @@ -141,9 +172,7 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr hasNodeAttributes={hasNodeAttrs} phase={phase} nodes={nodesByAttributes} - disableDataTierOption={Boolean( - isCloudEnabled && !hasDataNodeRoles && isUsingDeprecatedDataRoleConfig - )} + disableDataTierOption={Boolean(isCloudEnabled && isUsingDeprecatedDataRoleConfig)} isLoading={isLoading} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx index 97e7d0bcc27de..1a78149521e63 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx @@ -52,7 +52,7 @@ export const SearchableSnapshotField: FunctionComponent = ({ phase }) => services: { cloud }, } = useKibana(); const { getUrlForApp, policy, license, isNewPolicy } = useEditPolicyContext(); - const { isUsingSearchableSnapshotInHotPhase, isUsingRollover } = useConfigurationIssues(); + const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); const searchableSnapshotPath = `phases.${phase}.actions.searchable_snapshot.snapshot_repository`; @@ -62,10 +62,8 @@ export const SearchableSnapshotField: FunctionComponent = ({ phase }) => const isColdPhase = phase === 'cold'; const isDisabledDueToLicense = !license.canUseSearchableSnapshot(); const isDisabledInColdDueToHotPhase = isColdPhase && isUsingSearchableSnapshotInHotPhase; - const isDisabledInColdDueToRollover = isColdPhase && !isUsingRollover; - const isDisabled = - isDisabledDueToLicense || isDisabledInColdDueToHotPhase || isDisabledInColdDueToRollover; + const isDisabled = isDisabledDueToLicense || isDisabledInColdDueToHotPhase; const [isFieldToggleChecked, setIsFieldToggleChecked] = useState(() => Boolean( @@ -294,20 +292,6 @@ export const SearchableSnapshotField: FunctionComponent = ({ phase }) => )} /> ); - } else if (isDisabledInColdDueToRollover) { - infoCallout = ( - - ); } return infoCallout ? ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index e5bf34890a4a7..577dab6804147 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -33,22 +33,22 @@ export const WarmPhase: FunctionComponent = () => { const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); return ( - - + + - {!isUsingSearchableSnapshotInHotPhase && } + {!isUsingSearchableSnapshotInHotPhase && } - {!isUsingSearchableSnapshotInHotPhase && } + {!isUsingSearchableSnapshotInHotPhase && } - + {/* Data tier allocation section */} - + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/types.ts b/x-pack/plugins/index_lifecycle_management/public/types.ts index 27b3795c6731f..adfca9ad41b26 100644 --- a/x-pack/plugins/index_lifecycle_management/public/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/types.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx index f13ce33a44b3d..7cd6295cdcf40 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx @@ -6,12 +6,34 @@ */ import React, { useState, useCallback, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { AlertFlyout } from './alert_flyout'; import { useLinkProps } from '../../../hooks/use_link_props'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; + +const readOnlyUserTooltipContent = i18n.translate( + 'xpack.infra.logs.alertDropdown.readOnlyCreateAlertContent', + { + defaultMessage: 'Creating alerts requires more permissions in this application.', + } +); + +const readOnlyUserTooltipTitle = i18n.translate( + 'xpack.infra.logs.alertDropdown.readOnlyCreateAlertTitle', + { + defaultMessage: 'Read only', + } +); export const AlertDropdown = () => { + const { + services: { + application: { capabilities }, + }, + } = useKibanaContextForPlugin(); + const canCreateAlerts = capabilities?.logs?.save ?? false; const [popoverOpen, setPopoverOpen] = useState(false); const [flyoutVisible, setFlyoutVisible] = useState(false); const manageAlertsLinkProps = useLinkProps( @@ -34,7 +56,14 @@ export const AlertDropdown = () => { const menuItems = useMemo(() => { return [ - setFlyoutVisible(true)}> + setFlyoutVisible(true)} + toolTipContent={!canCreateAlerts ? readOnlyUserTooltipContent : undefined} + toolTipTitle={!canCreateAlerts ? readOnlyUserTooltipTitle : undefined} + > { /> , ]; - }, [manageAlertsLinkProps]); + }, [manageAlertsLinkProps, canCreateAlerts]); return ( <> diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts index 3a0bc009bcfd0..3fa6bc065e7c1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts @@ -78,7 +78,9 @@ export const useWaffleViewState = () => { region: newState.region, legend: newState.legend, }); - if (newState.time) { + // if this is the "Default View" view, don't update the time range to the view's time range, + // this way it will use the global Kibana time or the default time already set + if (newState.time && newState.id !== '0') { setWaffleTimeState({ currentTime: newState.time, isAutoReloading: newState.autoReload, @@ -100,4 +102,5 @@ export type WaffleViewState = WaffleOptionsState & { time: number; autoReload: boolean; filterQuery: WaffleFiltersState; + id?: string; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts index 424e456aa9dd8..eb5a4633d4fa9 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts @@ -24,6 +24,7 @@ export interface MetricExplorerViewState { chartOptions: MetricsExplorerChartOptions; currentTimerange: MetricsExplorerTimeOptions; options: MetricsExplorerOptions; + id?: string; } export const useMetricsExplorerState = ( @@ -42,6 +43,7 @@ export const useMetricsExplorerState = ( setTimeRange, setOptions, } = useContext(MetricsExplorerOptionsContainer.Context); + const { loading, error, data, loadData } = useMetricsExplorerData( options, source, @@ -121,7 +123,11 @@ export const useMetricsExplorerState = ( setChartOptions(vs.chartOptions); } if (vs.currentTimerange) { - setTimeRange(vs.currentTimerange); + // if this is the "Default View" view, don't update the time range to the view's time range, + // this way it will use the global Kibana time or the default time already set + if (vs.id !== '0') { + setTimeRange(vs.currentTimerange); + } } if (vs.options) { setOptions(vs.options); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx index 5f182203dd86c..37200f75d109c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx @@ -68,7 +68,6 @@ describe('useMetricExplorerOptions', () => { expect(result.current.currentTimerange).toEqual(DEFAULT_TIMERANGE); expect(result.current.isAutoReloading).toEqual(false); expect(STORE.MetricsExplorerOptions).toEqual(JSON.stringify(DEFAULT_OPTIONS)); - expect(STORE.MetricsExplorerTimeRange).toEqual(JSON.stringify(DEFAULT_TIMERANGE)); }); it('should change the store when options update', () => { @@ -96,7 +95,6 @@ describe('useMetricExplorerOptions', () => { }); rerender(); expect(result.current.currentTimerange).toEqual(newTimeRange); - expect(STORE.MetricsExplorerTimeRange).toEqual(JSON.stringify(newTimeRange)); }); it('should load from store when available', () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index 06140dd976691..c1e5be94acc03 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -163,10 +163,7 @@ export const useMetricsExplorerOptions = () => { 'MetricsExplorerOptions', DEFAULT_OPTIONS ); - const [currentTimerange, setTimeRange] = useStateWithLocalStorage( - 'MetricsExplorerTimeRange', - defaultTimeRange - ); + const [currentTimerange, setTimeRange] = useState(defaultTimeRange); useSyncKibanaTimeFilterTime(TIME_DEFAULTS, { from: currentTimerange.from, diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index e5594bb0bb769..7aa838021f2a8 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -1,16 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DragDrop defined dropType is reflected in the className 1`] = ` -

+ Hello! + `; exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = ` diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss index 9e3f1e1c3cf26..961f7ee0ec400 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -81,13 +81,6 @@ } } -.lnsDragDrop__container { - position: relative; - overflow: visible !important; // sass-lint:disable-line no-important - width: 100%; - height: 100%; -} - .lnsDragDrop__reorderableDrop { position: absolute; width: 100%; diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 618a7accb9b2b..76e44c29eaed5 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -456,7 +456,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost ? dragging.ghost : undefined; return ( -
+ <> {React.cloneElement(children, { 'data-test-subj': dataTestSubj || 'lnsDragDrop', className: classNames(children.props.className, classes, className), @@ -471,7 +471,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { style: ghost.style, }) : null} -
+ ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index d5c0b9ff64807..af5411dd4d3b0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -11,9 +11,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import { + EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiText, EuiButtonEmpty, EuiLink, @@ -389,72 +389,84 @@ export const InnerVisualizationWrapper = ({ if (localState.configurationValidationError?.length) { let showExtraErrors = null; + let showExtraErrorsAction = null; + if (localState.configurationValidationError.length > 1) { if (localState.expandError) { showExtraErrors = localState.configurationValidationError .slice(1) .map(({ longMessage }) => ( - {longMessage} - +

)); } else { - showExtraErrors = ( - - { - setLocalState((prevState: WorkspaceState) => ({ - ...prevState, - expandError: !prevState.expandError, - })); - }} - data-test-subj="configuration-failure-more-errors" - > - {i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', { - defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`, - values: { errors: localState.configurationValidationError.length - 1 }, - })} - - + showExtraErrorsAction = ( + { + setLocalState((prevState: WorkspaceState) => ({ + ...prevState, + expandError: !prevState.expandError, + })); + }} + data-test-subj="configuration-failure-more-errors" + > + {i18n.translate('xpack.lens.editorFrame.configurationFailureMoreErrors', { + defaultMessage: ` +{errors} {errors, plural, one {error} other {errors}}`, + values: { errors: localState.configurationValidationError.length - 1 }, + })} + ); } } return ( - + - - - - {localState.configurationValidationError[0].longMessage} + +

+ {localState.configurationValidationError[0].longMessage} +

+ + {showExtraErrors} + + } + iconColor="danger" + iconType="alert" + />
- {showExtraErrors}
); } if (localState.expressionBuildError?.length) { return ( - + - - - - +

+ +

+ +

{localState.expressionBuildError[0].longMessage}

+ + } + iconColor="danger" + iconType="alert" />
- {localState.expressionBuildError[0].longMessage}
); } @@ -474,34 +486,43 @@ export const InnerVisualizationWrapper = ({ const visibleErrorMessage = getOriginalRequestErrorMessage(error) || errorMessage; return ( - + - - - - { + setLocalState((prevState: WorkspaceState) => ({ + ...prevState, + expandError: !prevState.expandError, + })); + }} + > + {i18n.translate('xpack.lens.editorFrame.expandRenderingErrorButton', { + defaultMessage: 'Show details of error', + })} + + ) : null + } + body={ + <> +

+ +

+ + {localState.expandError ? ( +

visibleErrorMessage

+ ) : null} + + } + iconColor="danger" + iconType="alert" />
- {visibleErrorMessage ? ( - - { - setLocalState((prevState: WorkspaceState) => ({ - ...prevState, - expandError: !prevState.expandError, - })); - }} - > - {i18n.translate('xpack.lens.editorFrame.expandRenderingErrorButton', { - defaultMessage: 'Show details of error', - })} - - - {localState.expandError ? visibleErrorMessage : null} - - ) : null}
); }} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index 3949c7deb53b4..167c17ee6ae9c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -11,6 +11,7 @@ min-height: $euiSizeXXL * 10; overflow: visible; border: none; + height: 100%; .lnsWorkspacePanelWrapper__pageContentBody { @include euiScrollBar; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts index 4c40282012d6d..a676b7283671c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts @@ -5,10 +5,11 @@ * 2.0. */ -import { Capabilities, HttpSetup } from 'kibana/public'; +import { Capabilities, HttpSetup, SavedObjectReference } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Ast } from '@kbn/interpreter/target/common'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { IndexPatternsContract, TimefilterContract, @@ -105,4 +106,15 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { parent ); } + + extract(state: EmbeddableStateWithType) { + let references: SavedObjectReference[] = []; + const typedState = (state as unknown) as LensEmbeddableInput; + + if ('attributes' in typedState && typedState.attributes !== undefined) { + references = typedState.attributes.references; + } + + return { state, references }; + } } diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 6f8782c148d45..27e0fa29b1e55 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -8,7 +8,7 @@ import moment from 'moment'; import { OsTypeArray } from './schemas/common'; -import { EntriesArray } from './schemas/types'; +import { EntriesArray, Entry, EntryMatch, EntryNested } from './schemas/types'; import { EndpointEntriesArray } from './schemas/types/endpoint'; export const DATE_NOW = '2020-04-20T15:25:31.830Z'; export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z'; @@ -72,6 +72,34 @@ export const ENDPOINT_ENTRIES: EndpointEntriesArray = [ }, { field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' }, ]; +// ENTRIES_WITH_IDS should only be used to mock out functionality of a collection of transforms +// that are UI specific and useful for UI concerns that are inserted between the +// API and the actual user interface. In some ways these might be viewed as +// technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. +export const ENTRIES_WITH_IDS: EntriesArray = [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, +]; export const ITEM_TYPE = 'simple'; export const OS_TYPES: OsTypeArray = ['windows']; export const TAGS = []; diff --git a/x-pack/plugins/lists/common/shared_imports.ts b/x-pack/plugins/lists/common/shared_imports.ts index 7e96b13c036ec..2483c1f7dd992 100644 --- a/x-pack/plugins/lists/common/shared_imports.ts +++ b/x-pack/plugins/lists/common/shared_imports.ts @@ -12,6 +12,8 @@ export { DefaultStringArray, DefaultVersionNumber, DefaultVersionNumberDecoded, + addIdToItem, + removeIdFromItem, exactCheck, getPaths, foldLeftRight, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts index ed16a405a5107..7a39bd5651014 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.ts @@ -7,6 +7,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { ENTRIES_WITH_IDS } from '../../../common/constants.mock'; import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; import { getCreateExceptionListItemSchemaMock } from '../../../common/schemas/request/create_exception_list_item_schema.mock'; @@ -69,8 +70,8 @@ describe('usePersistExceptionItem', () => { }); test('it invokes "updateExceptionListItem" when payload has "id"', async () => { - const addExceptionItem = jest.spyOn(api, 'addExceptionListItem'); - const updateExceptionItem = jest.spyOn(api, 'updateExceptionListItem'); + const addExceptionListItem = jest.spyOn(api, 'addExceptionListItem'); + const updateExceptionListItem = jest.spyOn(api, 'updateExceptionListItem'); await act(async () => { const { result, waitForNextUpdate } = renderHook< PersistHookProps, @@ -78,12 +79,45 @@ describe('usePersistExceptionItem', () => { >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getUpdateExceptionListItemSchemaMock()); + // NOTE: Take note here passing in an exception item where it's + // entries have been enriched with ids to ensure that they get stripped + // before the call goes through + result.current[1]({ ...getUpdateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); - expect(addExceptionItem).not.toHaveBeenCalled(); - expect(updateExceptionItem).toHaveBeenCalled(); + expect(addExceptionListItem).not.toHaveBeenCalled(); + expect(updateExceptionListItem).toHaveBeenCalledWith({ + http: mockKibanaHttpService, + listItem: getUpdateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }); + }); + }); + + test('it invokes "addExceptionListItem" when payload does not have "id"', async () => { + const updateExceptionListItem = jest.spyOn(api, 'updateExceptionListItem'); + const addExceptionListItem = jest.spyOn(api, 'addExceptionListItem'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + PersistHookProps, + ReturnPersistExceptionItem + >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); + + await waitForNextUpdate(); + // NOTE: Take note here passing in an exception item where it's + // entries have been enriched with ids to ensure that they get stripped + // before the call goes through + result.current[1]({ ...getCreateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }); + await waitForNextUpdate(); + + expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + expect(updateExceptionListItem).not.toHaveBeenCalled(); + expect(addExceptionListItem).toHaveBeenCalledWith({ + http: mockKibanaHttpService, + listItem: getCreateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts index 995c6b8703bf4..6135d14aef6a4 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.ts @@ -7,9 +7,13 @@ import { Dispatch, useEffect, useState } from 'react'; -import { UpdateExceptionListItemSchema } from '../../../common/schemas'; +import { + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '../../../common/schemas'; import { addExceptionListItem, updateExceptionListItem } from '../api'; -import { AddExceptionListItem, PersistHookProps } from '../types'; +import { transformNewItemOutput, transformOutput } from '../transforms'; +import { PersistHookProps } from '../types'; interface PersistReturnExceptionItem { isLoading: boolean; @@ -18,7 +22,7 @@ interface PersistReturnExceptionItem { export type ReturnPersistExceptionItem = [ PersistReturnExceptionItem, - Dispatch + Dispatch ]; /** @@ -32,7 +36,9 @@ export const usePersistExceptionItem = ({ http, onError, }: PersistHookProps): ReturnPersistExceptionItem => { - const [exceptionListItem, setExceptionItem] = useState(null); + const [exceptionListItem, setExceptionItem] = useState< + CreateExceptionListItemSchema | UpdateExceptionListItemSchema | null + >(null); const [isSaved, setIsSaved] = useState(false); const [isLoading, setIsLoading] = useState(false); const isUpdateExceptionItem = (item: unknown): item is UpdateExceptionListItemSchema => @@ -47,16 +53,25 @@ export const usePersistExceptionItem = ({ if (exceptionListItem != null) { try { setIsLoading(true); + if (isUpdateExceptionItem(exceptionListItem)) { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedList = transformOutput(exceptionListItem); + await updateExceptionListItem({ http, - listItem: exceptionListItem, + listItem: transformedList, signal: abortCtrl.signal, }); } else { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedList = transformNewItemOutput(exceptionListItem); + await addExceptionListItem({ http, - listItem: exceptionListItem, + listItem: transformedList, signal: abortCtrl.signal, }); } diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts index e61e74ca33236..62f959cb386a0 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts @@ -7,6 +7,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { ENTRIES_WITH_IDS } from '../../../common/constants.mock'; import { getUpdateExceptionListItemSchemaMock } from '../../../common/schemas/request/update_exception_list_item_schema.mock'; import { coreMock } from '../../../../../../src/core/public/mocks'; import * as api from '../api'; @@ -24,6 +25,10 @@ import { import { ExceptionsApi, useApi } from './use_api'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const mockKibanaHttpService = coreMock.createStart().http; describe('useApi', () => { @@ -34,397 +39,428 @@ describe('useApi', () => { jest.clearAllMocks(); }); - test('it invokes "deleteExceptionListItemById" when "deleteExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnDeleteExceptionListItemById = jest - .spyOn(api, 'deleteExceptionListItemById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.deleteExceptionItem({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('deleteExceptionItem', () => { + test('it invokes "deleteExceptionListItemById" when "deleteExceptionItem" used', async () => { + const payload = getExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnDeleteExceptionListItemById = jest + .spyOn(api, 'deleteExceptionListItemById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.deleteExceptionItem({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnDeleteExceptionListItemById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnDeleteExceptionListItemById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "deleteExceptionListItemById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'deleteExceptionListItemById').mockRejectedValue(mockError); + test('invokes "onError" callback if "deleteExceptionListItemById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'deleteExceptionListItemById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListItemSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListItemSchemaMock(); - await result.current.deleteExceptionItem({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.deleteExceptionItem({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "deleteExceptionListById" when "deleteExceptionList" used', async () => { - const payload = getExceptionListSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnDeleteExceptionListById = jest - .spyOn(api, 'deleteExceptionListById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.deleteExceptionList({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('deleteExceptionList', () => { + test('it invokes "deleteExceptionListById" when "deleteExceptionList" used', async () => { + const payload = getExceptionListSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnDeleteExceptionListById = jest + .spyOn(api, 'deleteExceptionListById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.deleteExceptionList({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnDeleteExceptionListById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnDeleteExceptionListById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "deleteExceptionListById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'deleteExceptionListById').mockRejectedValue(mockError); + test('invokes "onError" callback if "deleteExceptionListById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'deleteExceptionListById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - await result.current.deleteExceptionList({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.deleteExceptionList({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "fetchExceptionListItemById" when "getExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListItemById = jest - .spyOn(api, 'fetchExceptionListItemById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.getExceptionItem({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('getExceptionItem', () => { + test('it invokes "fetchExceptionListItemById" when "getExceptionItem" used', async () => { + const payload = getExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListItemById = jest + .spyOn(api, 'fetchExceptionListItemById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.getExceptionItem({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + const expectedExceptionListItem = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + + expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalledWith(expectedExceptionListItem); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - test('invokes "onError" callback if "fetchExceptionListItemById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListItemById').mockRejectedValue(mockError); + test('invokes "onError" callback if "fetchExceptionListItemById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListItemById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - await result.current.getExceptionItem({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + await result.current.getExceptionItem({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - expect(onErrorMock).toHaveBeenCalledWith(mockError); + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); }); }); - test('it invokes "fetchExceptionListById" when "getExceptionList" used', async () => { - const payload = getExceptionListSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListById = jest - .spyOn(api, 'fetchExceptionListById') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - const { id, namespace_type: namespaceType } = payload; - - await result.current.getExceptionList({ - id, - namespaceType, - onError: jest.fn(), - onSuccess: onSuccessMock, + describe('getExceptionList', () => { + test('it invokes "fetchExceptionListById" when "getExceptionList" used', async () => { + const payload = getExceptionListSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListById = jest + .spyOn(api, 'fetchExceptionListById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.getExceptionList({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); }); - - const expected: ApiCallByIdProps = { - http: mockKibanaHttpService, - id, - namespaceType, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListById).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); - }); - - test('invokes "onError" callback if "fetchExceptionListById" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); + test('invokes "onError" callback if "fetchExceptionListById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError); - const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); - await result.current.getExceptionList({ - id, - namespaceType, - onError: onErrorMock, - onSuccess: jest.fn(), - }); + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); - expect(onErrorMock).toHaveBeenCalledWith(mockError); - }); - }); + await result.current.getExceptionList({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); - test('it invokes "fetchExceptionListsItemsByListIds" when "getExceptionItem" used', async () => { - const output = getFoundExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListsItemsByListIds = jest - .spyOn(api, 'fetchExceptionListsItemsByListIds') - .mockResolvedValue(output); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: jest.fn(), - onSuccess: onSuccessMock, - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: false, + expect(onErrorMock).toHaveBeenCalledWith(mockError); }); - - const expected: ApiCallByListIdProps = { - filterOptions: [], - http: mockKibanaHttpService, - listIds: ['list_id'], - namespaceTypes: ['single'], - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListsItemsByListIds).toHaveBeenCalledWith(expected); - expect(onSuccessMock).toHaveBeenCalled(); }); }); - test('it does not invoke "fetchExceptionListsItemsByListIds" if no listIds', async () => { - const output = getFoundExceptionListItemSchemaMock(); - const onSuccessMock = jest.fn(); - const spyOnFetchExceptionListsItemsByListIds = jest - .spyOn(api, 'fetchExceptionListsItemsByListIds') - .mockResolvedValue(output); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: jest.fn(), - onSuccess: onSuccessMock, - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: true, + describe('getExceptionListsItems', () => { + test('it invokes "fetchExceptionListsItemsByListIds" when "getExceptionListsItems" used', async () => { + const output = getFoundExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') + .mockResolvedValue(output); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 1, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }); + + const expected: ApiCallByListIdProps = { + filterOptions: [], + http: mockKibanaHttpService, + listIds: ['list_id'], + namespaceTypes: ['single'], + pagination: { + page: 1, + perPage: 1, + total: 0, + }, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListsItemsByListIds).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalledWith({ + exceptions: [{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }], + pagination: { + page: 1, + perPage: 1, + total: 1, + }, + }); }); + }); - expect(spyOnFetchExceptionListsItemsByListIds).not.toHaveBeenCalled(); - expect(onSuccessMock).toHaveBeenCalledWith({ - exceptions: [], - pagination: { - page: 0, - perPage: 20, - total: 0, - }, + test('it does not invoke "fetchExceptionListsItemsByListIds" if no listIds', async () => { + const output = getFoundExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListsItemsByListIds = jest + .spyOn(api, 'fetchExceptionListsItemsByListIds') + .mockResolvedValue(output); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: true, + }); + + expect(spyOnFetchExceptionListsItemsByListIds).not.toHaveBeenCalled(); + expect(onSuccessMock).toHaveBeenCalledWith({ + exceptions: [], + pagination: { + page: 0, + perPage: 20, + total: 0, + }, + }); }); }); - }); - test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => { - const mockError = new Error('failed to delete item'); - jest.spyOn(api, 'fetchExceptionListsItemsByListIds').mockRejectedValue(mockError); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.getExceptionListsItems({ - filterOptions: [], - lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }], - onError: onErrorMock, - onSuccess: jest.fn(), - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - showDetectionsListsOnly: false, - showEndpointListsOnly: false, + test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListsItemsByListIds').mockRejectedValue(mockError); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.getExceptionListsItems({ + filterOptions: [], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], + onError: onErrorMock, + onSuccess: jest.fn(), + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + showDetectionsListsOnly: false, + showEndpointListsOnly: false, + }); + + expect(onErrorMock).toHaveBeenCalledWith(mockError); }); - - expect(onErrorMock).toHaveBeenCalledWith(mockError); }); }); - test('it invokes "addExceptionListItem" when "addExceptionListItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const itemToCreate = getCreateExceptionListItemSchemaMock(); - const spyOnFetchExceptionListItemById = jest - .spyOn(api, 'addExceptionListItem') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.addExceptionListItem({ - listItem: itemToCreate, + describe('addExceptionListItem', () => { + test('it removes exception item entry ids', async () => { + const payload = getExceptionListItemSchemaMock(); + const itemToCreate = { ...getCreateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }; + const spyOnFetchExceptionListItemById = jest + .spyOn(api, 'addExceptionListItem') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.addExceptionListItem({ + listItem: itemToCreate, + }); + + const expected: AddExceptionListItemProps = { + http: mockKibanaHttpService, + listItem: getCreateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); }); - - const expected: AddExceptionListItemProps = { - http: mockKibanaHttpService, - listItem: itemToCreate, - signal: new AbortController().signal, - }; - - expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); }); }); - test('it invokes "updateExceptionListItem" when "getExceptionItem" used', async () => { - const payload = getExceptionListItemSchemaMock(); - const itemToUpdate = getUpdateExceptionListItemSchemaMock(); - const spyOnUpdateExceptionListItem = jest - .spyOn(api, 'updateExceptionListItem') - .mockResolvedValue(payload); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useApi(mockKibanaHttpService) - ); - await waitForNextUpdate(); - - await result.current.updateExceptionListItem({ - listItem: itemToUpdate, + describe('updateExceptionListItem', () => { + test('it removes exception item entry ids', async () => { + const payload = getExceptionListItemSchemaMock(); + const itemToUpdate = { ...getUpdateExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }; + const spyOnUpdateExceptionListItem = jest + .spyOn(api, 'updateExceptionListItem') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + await result.current.updateExceptionListItem({ + listItem: itemToUpdate, + }); + + const expected: UpdateExceptionListItemProps = { + http: mockKibanaHttpService, + listItem: getUpdateExceptionListItemSchemaMock(), + signal: new AbortController().signal, + }; + + expect(spyOnUpdateExceptionListItem).toHaveBeenCalledWith(expected); }); - - const expected: UpdateExceptionListItemProps = { - http: mockKibanaHttpService, - listItem: itemToUpdate, - signal: new AbortController().signal, - }; - - expect(spyOnUpdateExceptionListItem).toHaveBeenCalledWith(expected); }); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts index b0c831ef3b857..9e4e338b09dbf 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.ts @@ -17,6 +17,7 @@ import { } from '../../../common/schemas'; import { ApiCallFindListsItemsMemoProps, ApiCallMemoProps, ApiListExportProps } from '../types'; import { getIdsAndNamespaces } from '../utils'; +import { transformInput, transformNewItemOutput, transformOutput } from '../transforms'; export interface ExceptionsApi { addExceptionListItem: (arg: { @@ -46,10 +47,11 @@ export const useApi = (http: HttpStart): ExceptionsApi => { listItem: CreateExceptionListItemSchema; }): Promise { const abortCtrl = new AbortController(); + const sanitizedItem: CreateExceptionListItemSchema = transformNewItemOutput(listItem); return Api.addExceptionListItem({ http, - listItem, + listItem: sanitizedItem, signal: abortCtrl.signal, }); }, @@ -124,12 +126,14 @@ export const useApi = (http: HttpStart): ExceptionsApi => { const abortCtrl = new AbortController(); try { - const item = await Api.fetchExceptionListItemById({ - http, - id, - namespaceType, - signal: abortCtrl.signal, - }); + const item = transformInput( + await Api.fetchExceptionListItemById({ + http, + id, + namespaceType, + signal: abortCtrl.signal, + }) + ); onSuccess(item); } catch (error) { onError(error); @@ -187,7 +191,10 @@ export const useApi = (http: HttpStart): ExceptionsApi => { signal: abortCtrl.signal, }); onSuccess({ - exceptions: data, + // This data transform is UI specific and useful for UI concerns + // to compensate for the differences and preferences of how ReactJS might prefer + // data vs. how we want to model data. View `transformInput` for more details + exceptions: data.map((item) => transformInput(item)), pagination: { page, perPage, @@ -214,10 +221,11 @@ export const useApi = (http: HttpStart): ExceptionsApi => { listItem: UpdateExceptionListItemSchema; }): Promise { const abortCtrl = new AbortController(); + const sanitizedItem: UpdateExceptionListItemSchema = transformOutput(listItem); return Api.updateExceptionListItem({ http, - listItem, + listItem: sanitizedItem, signal: abortCtrl.signal, }); }, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts index d5d2638781879..1191b240d27bb 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.test.ts @@ -12,9 +12,14 @@ import * as api from '../api'; import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { ExceptionListItemSchema } from '../../../common/schemas'; import { UseExceptionListItemsSuccess, UseExceptionListProps } from '../types'; +import { transformInput } from '../transforms'; import { ReturnExceptionListAndItems, useExceptionListItems } from './use_exception_list_items'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const mockKibanaHttpService = coreMock.createStart().http; describe('useExceptionListItems', () => { @@ -99,8 +104,9 @@ describe('useExceptionListItems', () => { await waitForNextUpdate(); await waitForNextUpdate(); - const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock() - .data; + const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock().data.map( + (item) => transformInput(item) + ); const expectedResult: UseExceptionListItemsSuccess = { exceptions: expectedListItemsResult, pagination: { page: 1, perPage: 1, total: 1 }, diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts index 50271530b42e7..b9a8628d2ceac 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list_items.ts @@ -11,6 +11,7 @@ import { fetchExceptionListsItemsByListIds } from '../api'; import { FilterExceptionsOptions, Pagination, UseExceptionListProps } from '../types'; import { ExceptionListItemSchema } from '../../../common/schemas'; import { getIdsAndNamespaces } from '../utils'; +import { transformInput } from '../transforms'; type Func = () => void; export type ReturnExceptionListAndItems = [ @@ -95,8 +96,12 @@ export const useExceptionListItems = ({ } setLoading(false); } else { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { page, per_page, total, data } = await fetchExceptionListsItemsByListIds({ + const { + page, + per_page: perPage, + total, + data, + } = await fetchExceptionListsItemsByListIds({ filterOptions: filters, http, listIds: ids, @@ -108,20 +113,24 @@ export const useExceptionListItems = ({ signal: abortCtrl.signal, }); + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + const transformedData = data.map((item) => transformInput(item)); + if (isSubscribed) { setPagination({ page, - perPage: per_page, + perPage, total, }); - setExceptionListItems(data); + setExceptionListItems(transformedData); if (onSuccess != null) { onSuccess({ - exceptions: data, + exceptions: transformedData, pagination: { page, - perPage: per_page, + perPage, total, }, }); diff --git a/x-pack/plugins/lists/public/exceptions/transforms.test.ts b/x-pack/plugins/lists/public/exceptions/transforms.test.ts new file mode 100644 index 0000000000000..12b0f0bd8624a --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/transforms.test.ts @@ -0,0 +1,282 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExceptionListItemSchema } from '../../common/schemas/response/exception_list_item_schema'; +import { UpdateExceptionListItemSchema } from '../../common/schemas/request/update_exception_list_item_schema'; +import { CreateExceptionListItemSchema } from '../../common/schemas/request/create_exception_list_item_schema'; +import { getCreateExceptionListItemSchemaMock } from '../../common/schemas/request/create_exception_list_item_schema.mock'; +import { getUpdateExceptionListItemSchemaMock } from '../../common/schemas/request/update_exception_list_item_schema.mock'; +import { getExceptionListItemSchemaMock } from '../../common/schemas/response/exception_list_item_schema.mock'; +import { ENTRIES_WITH_IDS } from '../../common/constants.mock'; +import { Entry, EntryMatch, EntryNested } from '../../common/schemas'; + +import { + addIdToExceptionItemEntries, + removeIdFromExceptionItemsEntries, + transformInput, + transformOutput, +} from './transforms'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +describe('Exceptions transforms', () => { + describe('transformOutput', () => { + it('returns same output as input with stripped ids per entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + const output = transformOutput(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = getCreateExceptionListItemSchemaMock(); + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + const output = transformOutput(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = getUpdateExceptionListItemSchemaMock(); + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('transformInput', () => { + it('returns same output as input with added ids per entry', () => { + const mockExceptionItem = getExceptionListItemSchemaMock(); + const output = transformInput(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('addIdToExceptionItemEntries', () => { + it('returns same output as input with added ids per entry', () => { + const mockExceptionItem: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + const output = addIdToExceptionItemEntries(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with added ids per nested entry', () => { + const mockExceptionItem: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + const output = addIdToExceptionItemEntries(mockExceptionItem); + const expectedOutput: ExceptionListItemSchema = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + }); + + describe('removeIdFromExceptionItemsEntries', () => { + it('returns same output as input with stripped ids per entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per nested entry - CreateExceptionListItemSchema', () => { + const mockCreateExceptionItem = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockCreateExceptionItem); + const expectedOutput: CreateExceptionListItemSchema = { + ...getCreateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as Entry & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + + it('returns same output as input with stripped ids per nested entry - UpdateExceptionListItemSchema', () => { + const mockUpdateExceptionItem = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + id: '123', + operator: 'included', + type: 'match', + value: 'some value', + } as EntryMatch & { id: string }, + ], + field: 'some.parentField', + id: '123', + type: 'nested', + } as EntryNested & { id: string }, + ], + }; + const output = removeIdFromExceptionItemsEntries(mockUpdateExceptionItem); + const expectedOutput: UpdateExceptionListItemSchema = { + ...getUpdateExceptionListItemSchemaMock(), + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + ], + }; + + expect(output).toEqual(expectedOutput); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/transforms.ts b/x-pack/plugins/lists/public/exceptions/transforms.ts new file mode 100644 index 0000000000000..0791760611bf5 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/transforms.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { flow } from 'fp-ts/lib/function'; + +import { + CreateExceptionListItemSchema, + EntriesArray, + Entry, + ExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '../../common'; +import { addIdToItem, removeIdFromItem } from '../../common/shared_imports'; + +// These are a collection of transforms that are UI specific and useful for UI concerns +// that are inserted between the API and the actual user interface. In some ways these +// might be viewed as technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. Each function should have +// a description giving context around the transform. + +/** + * Transforms the output of exception items to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the output called "myNewTransform" do it + * in the form of: + * flow(removeIdFromExceptionItemsEntries, myNewTransform)(exceptionItem) + * + * @param exceptionItem The exceptionItem to transform the output of + * @returns The exceptionItem transformed from the output + */ +export const transformOutput = ( + exceptionItem: UpdateExceptionListItemSchema | ExceptionListItemSchema +): UpdateExceptionListItemSchema | ExceptionListItemSchema => + flow(removeIdFromExceptionItemsEntries)(exceptionItem); + +export const transformNewItemOutput = ( + exceptionItem: CreateExceptionListItemSchema +): CreateExceptionListItemSchema => flow(removeIdFromExceptionItemsEntries)(exceptionItem); + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the input called "myNewTransform" do it + * in the form of: + * flow(addIdToExceptionItemEntries, myNewTransform)(exceptionItem) + * + * @param exceptionItem The exceptionItem to transform the output of + * @returns The exceptionItem transformed from the output + */ +export const transformInput = (exceptionItem: ExceptionListItemSchema): ExceptionListItemSchema => + flow(addIdToExceptionItemEntries)(exceptionItem); + +/** + * This adds an id to the incoming exception item entries as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * This does break the type system slightly as we are lying a bit to the type system as we return + * the same exceptionItem as we have previously but are augmenting the arrays with an id which TypeScript + * doesn't mind us doing here. However, downstream you will notice that you have an id when the type + * does not indicate it. In that case use (ExceptionItem & { id: string }) temporarily if you're using the id. If you're not, + * you can ignore the id and just use the normal TypeScript with ReactJS. + * + * @param exceptionItem The exceptionItem to add an id to the threat matches. + * @returns exceptionItem The exceptionItem but with id added to the exception item entries + */ +export const addIdToExceptionItemEntries = ( + exceptionItem: ExceptionListItemSchema +): ExceptionListItemSchema => { + const entries = exceptionItem.entries.map((entry) => { + if (entry.type === 'nested') { + return addIdToItem({ + ...entry, + entries: entry.entries.map((nestedEntry) => addIdToItem(nestedEntry)), + }); + } else { + return addIdToItem(entry); + } + }); + return { ...exceptionItem, entries }; +}; + +/** + * This removes an id from the exceptionItem entries as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * @param exceptionItem The exceptionItem to remove an id from the entries. + * @returns exceptionItem The exceptionItem but with id removed from the entries + */ +export const removeIdFromExceptionItemsEntries = ( + exceptionItem: T +): T => { + const { entries } = exceptionItem; + const entriesNoId = entries.map((entry) => { + if (entry.type === 'nested') { + return removeIdFromItem({ + ...entry, + entries: entry.entries.map((nestedEntry) => removeIdFromItem(nestedEntry)), + }); + } else { + return removeIdFromItem(entry); + } + }); + return { ...exceptionItem, entries: entriesNoId }; +}; diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index e37c03978c9f6..03cae387711f8 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -33,8 +33,6 @@ export interface Pagination { export type AddExceptionList = UpdateExceptionListSchema | CreateExceptionListSchema; -export type AddExceptionListItem = CreateExceptionListItemSchema | UpdateExceptionListItemSchema; - export interface PersistHookProps { http: HttpStart; onError: (arg: Error) => void; diff --git a/x-pack/plugins/maps/common/migrations/references.js b/x-pack/plugins/maps/common/migrations/references.js deleted file mode 100644 index ab8edbefb27c2..0000000000000 --- a/x-pack/plugins/maps/common/migrations/references.js +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// Can not use public Layer classes to extract references since this logic must run in both client and server. - -import _ from 'lodash'; -import { SOURCE_TYPES } from '../constants'; - -function doesSourceUseIndexPattern(layerDescriptor) { - const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); - return ( - sourceType === SOURCE_TYPES.ES_GEO_GRID || - sourceType === SOURCE_TYPES.ES_SEARCH || - sourceType === SOURCE_TYPES.ES_PEW_PEW - ); -} - -export function extractReferences({ attributes, references = [] }) { - if (!attributes.layerListJSON) { - return { attributes, references }; - } - - const extractedReferences = []; - - const layerList = JSON.parse(attributes.layerListJSON); - layerList.forEach((layer, layerIndex) => { - // Extract index-pattern references from source descriptor - if (doesSourceUseIndexPattern(layer) && _.has(layer, 'sourceDescriptor.indexPatternId')) { - const refName = `layer_${layerIndex}_source_index_pattern`; - extractedReferences.push({ - name: refName, - type: 'index-pattern', - id: layer.sourceDescriptor.indexPatternId, - }); - delete layer.sourceDescriptor.indexPatternId; - layer.sourceDescriptor.indexPatternRefName = refName; - } - - // Extract index-pattern references from join - const joins = _.get(layer, 'joins', []); - joins.forEach((join, joinIndex) => { - if (_.has(join, 'right.indexPatternId')) { - const refName = `layer_${layerIndex}_join_${joinIndex}_index_pattern`; - extractedReferences.push({ - name: refName, - type: 'index-pattern', - id: join.right.indexPatternId, - }); - delete join.right.indexPatternId; - join.right.indexPatternRefName = refName; - } - }); - }); - - return { - attributes: { - ...attributes, - layerListJSON: JSON.stringify(layerList), - }, - references: references.concat(extractedReferences), - }; -} - -function findReference(targetName, references) { - const reference = references.find((reference) => reference.name === targetName); - if (!reference) { - throw new Error(`Could not find reference "${targetName}"`); - } - return reference; -} - -export function injectReferences({ attributes, references }) { - if (!attributes.layerListJSON) { - return { attributes }; - } - - const layerList = JSON.parse(attributes.layerListJSON); - layerList.forEach((layer) => { - // Inject index-pattern references into source descriptor - if (doesSourceUseIndexPattern(layer) && _.has(layer, 'sourceDescriptor.indexPatternRefName')) { - const reference = findReference(layer.sourceDescriptor.indexPatternRefName, references); - layer.sourceDescriptor.indexPatternId = reference.id; - delete layer.sourceDescriptor.indexPatternRefName; - } - - // Inject index-pattern references into join - const joins = _.get(layer, 'joins', []); - joins.forEach((join) => { - if (_.has(join, 'right.indexPatternRefName')) { - const reference = findReference(join.right.indexPatternRefName, references); - join.right.indexPatternId = reference.id; - delete join.right.indexPatternRefName; - } - }); - }); - - return { - attributes: { - ...attributes, - layerListJSON: JSON.stringify(layerList), - }, - }; -} diff --git a/x-pack/plugins/maps/common/migrations/references.test.js b/x-pack/plugins/maps/common/migrations/references.test.ts similarity index 98% rename from x-pack/plugins/maps/common/migrations/references.test.js rename to x-pack/plugins/maps/common/migrations/references.test.ts index 3b8b7de441be4..5b749022bb62b 100644 --- a/x-pack/plugins/maps/common/migrations/references.test.js +++ b/x-pack/plugins/maps/common/migrations/references.test.ts @@ -128,7 +128,7 @@ describe('injectReferences', () => { const attributes = { title: 'my map', }; - expect(injectReferences({ attributes })).toEqual({ + expect(injectReferences({ attributes, references: [] })).toEqual({ attributes: { title: 'my map', }, diff --git a/x-pack/plugins/maps/common/migrations/references.ts b/x-pack/plugins/maps/common/migrations/references.ts new file mode 100644 index 0000000000000..d48be6bd56fbe --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/references.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Can not use public Layer classes to extract references since this logic must run in both client and server. + +import { SavedObjectReference } from '../../../../../src/core/types'; +import { MapSavedObjectAttributes } from '../map_saved_object_type'; +import { LayerDescriptor } from '../descriptor_types'; + +interface IndexPatternReferenceDescriptor { + indexPatternId?: string; + indexPatternRefName?: string; +} + +export function extractReferences({ + attributes, + references = [], +}: { + attributes: MapSavedObjectAttributes; + references?: SavedObjectReference[]; +}) { + if (!attributes.layerListJSON) { + return { attributes, references }; + } + + const extractedReferences: SavedObjectReference[] = []; + + const layerList: LayerDescriptor[] = JSON.parse(attributes.layerListJSON); + layerList.forEach((layer, layerIndex) => { + // Extract index-pattern references from source descriptor + if (layer.sourceDescriptor && 'indexPatternId' in layer.sourceDescriptor) { + const sourceDescriptor = layer.sourceDescriptor as IndexPatternReferenceDescriptor; + const refName = `layer_${layerIndex}_source_index_pattern`; + extractedReferences.push({ + name: refName, + type: 'index-pattern', + id: sourceDescriptor.indexPatternId!, + }); + delete sourceDescriptor.indexPatternId; + sourceDescriptor.indexPatternRefName = refName; + } + + // Extract index-pattern references from join + const joins = layer.joins ? layer.joins : []; + joins.forEach((join, joinIndex) => { + if ('indexPatternId' in join.right) { + const sourceDescriptor = join.right as IndexPatternReferenceDescriptor; + const refName = `layer_${layerIndex}_join_${joinIndex}_index_pattern`; + extractedReferences.push({ + name: refName, + type: 'index-pattern', + id: sourceDescriptor.indexPatternId!, + }); + delete sourceDescriptor.indexPatternId; + sourceDescriptor.indexPatternRefName = refName; + } + }); + }); + + return { + attributes: { + ...attributes, + layerListJSON: JSON.stringify(layerList), + }, + references: references.concat(extractedReferences), + }; +} + +function findReference(targetName: string, references: SavedObjectReference[]) { + const reference = references.find(({ name }) => name === targetName); + if (!reference) { + throw new Error(`Could not find reference "${targetName}"`); + } + return reference; +} + +export function injectReferences({ + attributes, + references, +}: { + attributes: MapSavedObjectAttributes; + references: SavedObjectReference[]; +}) { + if (!attributes.layerListJSON) { + return { attributes }; + } + + const layerList: LayerDescriptor[] = JSON.parse(attributes.layerListJSON); + layerList.forEach((layer) => { + // Inject index-pattern references into source descriptor + if (layer.sourceDescriptor && 'indexPatternRefName' in layer.sourceDescriptor) { + const sourceDescriptor = layer.sourceDescriptor as IndexPatternReferenceDescriptor; + const reference = findReference(sourceDescriptor.indexPatternRefName!, references); + sourceDescriptor.indexPatternId = reference.id; + delete sourceDescriptor.indexPatternRefName; + } + + // Inject index-pattern references into join + const joins = layer.joins ? layer.joins : []; + joins.forEach((join) => { + if ('indexPatternRefName' in join.right) { + const sourceDescriptor = join.right as IndexPatternReferenceDescriptor; + const reference = findReference(sourceDescriptor.indexPatternRefName!, references); + sourceDescriptor.indexPatternId = reference.id; + delete sourceDescriptor.indexPatternRefName; + } + }); + }); + + return { + attributes: { + ...attributes, + layerListJSON: JSON.stringify(layerList), + }, + }; +} 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 b039076305498..b1944f8136709 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { EmbeddableFactoryDefinition, IContainer, @@ -13,8 +14,9 @@ import { import '../index.scss'; import { MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; import { getMapEmbeddableDisplayName } from '../../common/i18n_getters'; -import { MapByReferenceInput, MapEmbeddableInput } from './types'; +import { MapByReferenceInput, MapEmbeddableInput, MapByValueInput } from './types'; import { lazyLoadMapModules } from '../lazy_load_bundle'; +import { extractReferences } from '../../common/migrations/references'; export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { type = MAP_SAVED_OBJECT_TYPE; @@ -61,4 +63,18 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { parent ); }; + + extract(state: EmbeddableStateWithType) { + const maybeMapByValueInput = state as EmbeddableStateWithType | MapByValueInput; + + if ((maybeMapByValueInput as MapByValueInput).attributes !== undefined) { + const { references } = extractReferences({ + attributes: (maybeMapByValueInput as MapByValueInput).attributes, + }); + + return { state, references }; + } + + return { state, references: [] }; + } } diff --git a/x-pack/plugins/maps/public/map_attribute_service.ts b/x-pack/plugins/maps/public/map_attribute_service.ts index 71b0b7f28a2ff..5f7c45b1b42d7 100644 --- a/x-pack/plugins/maps/public/map_attribute_service.ts +++ b/x-pack/plugins/maps/public/map_attribute_service.ts @@ -12,7 +12,6 @@ import { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; import { getMapEmbeddableDisplayName } from '../common/i18n_getters'; import { checkForDuplicateTitle, OnSaveProps } from '../../../../src/plugins/saved_objects/public'; import { getCoreOverlays, getEmbeddableService, getSavedObjectsClient } from './kibana_services'; -// @ts-expect-error import { extractReferences, injectReferences } from '../common/migrations/references'; import { MapByValueInput, MapByReferenceInput } from './embeddable/types'; diff --git a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts index 8f9b529ae30f5..bf180c514c56f 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -24,7 +24,6 @@ import { import { MapSavedObject, MapSavedObjectAttributes } from '../../common/map_saved_object_type'; import { getIndexPatternsService, getInternalRepository } from '../kibana_server_services'; import { MapsConfigType } from '../../config'; -// @ts-expect-error import { injectReferences } from '././../../common/migrations/references'; interface Settings { @@ -314,7 +313,10 @@ export async function getMapsTelemetry(config: MapsConfigType): Promise { const savedObjectsWithIndexPatternIds = savedObjects.map((savedObject) => { - return injectReferences(savedObject); + return { + ...savedObject, + ...injectReferences(savedObject), + }; }); return layerLists.push(...getLayerLists(savedObjectsWithIndexPatternIds)); } diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx index 740d127e1b08d..344464bfe9590 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx @@ -14,6 +14,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiIconTip, EuiSelect, EuiSpacer, EuiSwitch, @@ -57,6 +58,24 @@ const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); +interface OptionLabelWithIconTipProps { + label: string; + tooltip: string; +} + +const OptionLabelWithIconTip: FC = ({ label, tooltip }) => ( + <> + {label} + + +); + export interface ScatterplotMatrixProps { fields: string[]; index: string; @@ -252,9 +271,16 @@ export const ScatterplotMatrix: FC = ({ + } display="rowCompressed" fullWidth > @@ -276,9 +302,16 @@ export const ScatterplotMatrix: FC = ({ + } display="rowCompressed" fullWidth > @@ -292,9 +325,17 @@ export const ScatterplotMatrix: FC = ({ + } display="rowCompressed" fullWidth > @@ -310,9 +351,16 @@ export const ScatterplotMatrix: FC = ({ {resultsField !== undefined && legendType === undefined && ( + } display="rowCompressed" fullWidth > diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss index d946a0cf94032..65a72a1d4a48d 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/time_range_selector/_time_range_selector.scss @@ -46,6 +46,7 @@ } .body { display: block; + height: 315px; } } & > li.has-body.active { diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts b/x-pack/plugins/security_solution/common/add_remove_id_to_item.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts rename to x-pack/plugins/security_solution/common/add_remove_id_to_item.test.ts diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts b/x-pack/plugins/security_solution/common/add_remove_id_to_item.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts rename to x-pack/plugins/security_solution/common/add_remove_id_to_item.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 31b4cef1a9d45..c277ce369dca0 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -45,10 +45,10 @@ export const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000; // ms export const DEFAULT_RULE_REFRESH_IDLE_VALUE = 2700000; // ms export const DEFAULT_RULE_NOTIFICATION_QUERY_SIZE = 100; -// Document path where threat indicator fields are expected. Used as -// both the source of enrichment fields and the destination for enrichment in -// the generated detection alert -export const DEFAULT_INDICATOR_PATH = 'threat.indicator'; +// Document path where threat indicator fields are expected. Fields are used +// to enrich signals, and are copied to threat.indicator. +export const DEFAULT_INDICATOR_SOURCE_PATH = 'threatintel.indicator'; +export const INDICATOR_DESTINATION_PATH = 'threat.indicator'; export enum SecurityPageName { detections = 'detections', diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index d97820f010a80..bfe450d240b08 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -444,13 +444,19 @@ export const threat_technique = t.intersection([ ]); export type ThreatTechnique = t.TypeOf; export const threat_techniques = t.array(threat_technique); -export const threat = t.exact( - t.type({ - framework: threat_framework, - tactic: threat_tactic, - technique: threat_techniques, - }) -); +export const threat = t.intersection([ + t.exact( + t.type({ + framework: threat_framework, + tactic: threat_tactic, + }) + ), + t.exact( + t.partial({ + technique: threat_techniques, + }) + ), +]); export type Threat = t.TypeOf; export const threats = t.array(threat); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts index 93094e3445488..f3bef5ad7445f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts @@ -924,7 +924,7 @@ describe('add prepackaged rules schema', () => { expect(message.schema).toEqual({}); }); - test('You cannot send in an array of threat that are missing "technique"', () => { + test('You can send in an array of threat that are missing "technique"', () => { const payload: Omit & { threat: Array>>; } = { @@ -944,10 +944,21 @@ describe('add prepackaged rules schema', () => { const decoded = addPrepackagedRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "threat,technique"', - ]); - expect(message.schema).toEqual({}); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: AddPrepackagedRulesSchemaDecoded = { + ...getAddPrepackagedRulesSchemaDecodedMock(), + threat: [ + { + framework: 'fake', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + }, + ], + }; + expect(message.schema).toEqual(expected); }); test('You can optionally send in an array of false positives', () => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts index a59c873658411..2caedd2e01193 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts @@ -926,7 +926,7 @@ describe('import rules schema', () => { expect(message.schema).toEqual({}); }); - test('You cannot send in an array of threat that are missing "technique"', () => { + test('You can send in an array of threat that are missing "technique"', () => { const payload: Omit & { threat: Array>>; } = { @@ -946,10 +946,21 @@ describe('import rules schema', () => { const decoded = importRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "threat,technique"', - ]); - expect(message.schema).toEqual({}); + expect(getPaths(left(message.errors))).toEqual([]); + const expected: ImportRulesSchemaDecoded = { + ...getImportRulesSchemaDecodedMock(), + threat: [ + { + framework: 'fake', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + }, + ], + }; + expect(message.schema).toEqual(expected); }); test('You can optionally send in an array of false positives', () => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts index 8cdb85a555451..3dfa12acc29d5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts @@ -973,7 +973,7 @@ describe('patch_rules_schema', () => { expect(message.schema).toEqual({}); }); - test('threat is invalid when updated with missing technique', () => { + test('threat is valid when updated with missing technique', () => { const threat: Omit = [ { framework: 'fake', @@ -993,10 +993,8 @@ describe('patch_rules_schema', () => { const decoded = patchRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "threat,technique"', - ]); - expect(message.schema).toEqual({}); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); }); test('validates with timeline_id and timeline_title', () => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts index 3bae9551f4df7..a51c1f77844d5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../constants'; import { MachineLearningCreateSchema, MachineLearningUpdateSchema, @@ -57,7 +57,7 @@ export const getCreateThreatMatchRulesSchemaMock = ( rule_id: ruleId, threat_query: '*:*', threat_index: ['list-index'], - threat_indicator_path: DEFAULT_INDICATOR_PATH, + threat_indicator_path: DEFAULT_INDICATOR_SOURCE_PATH, threat_mapping: [ { entries: [ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts index 6b8211b23088c..70ff921d3b334 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts @@ -618,7 +618,7 @@ describe('create rules schema', () => { expect(message.schema).toEqual({}); }); - test('You cannot send in an array of threat that are missing "technique"', () => { + test('You can send in an array of threat that are missing "technique"', () => { const payload = { ...getCreateRulesSchemaMock(), threat: [ @@ -636,10 +636,8 @@ describe('create rules schema', () => { const decoded = createRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "threat,technique"', - ]); - expect(message.schema).toEqual({}); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); }); test('You can optionally send in an array of false positives', () => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index 8dc7427ed0933..730e2949d7a11 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../constants'; import { getListArrayMock } from '../types/lists.mock'; import { RulesSchema } from './rules_schema'; @@ -151,7 +151,7 @@ export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial { cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore); }); }); + + describe('Duplicates the indicator rule', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + goToManageAlertsDetectionRules(); + createCustomIndicatorRule(newThreatIndicatorRule); + reload(); + }); + + it('Allows the rule to be duplicated from the table', () => { + waitForKibana(); + duplicateFirstRule(); + cy.contains(RULE_NAME, `${newThreatIndicatorRule.name} [Duplicate]`); + }); + + it('Allows the rule to be duplicated from the edit screen', () => { + waitForKibana(); + goToRuleDetails(); + duplicateRuleFromMenu(); + goBackToAllRulesTable(); + reload(); + cy.contains(RULE_NAME, `${newThreatIndicatorRule.name} [Duplicate]`); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts new file mode 100644 index 0000000000000..154e90d509c61 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { newRule } from '../../objects/rule'; + +import { RULE_STATUS } from '../../screens/create_new_rule'; + +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { createCustomRule } from '../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; +import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { openExceptionModalFromRuleSettings, goToExceptionsTab } from '../../tasks/rule_details'; +import { + addExceptionEntryFieldValue, + addExceptionEntryFieldValueOfItemX, + closeExceptionBuilderModal, +} from '../../tasks/exceptions'; +import { + ADD_AND_BTN, + ADD_OR_BTN, + ADD_NESTED_BTN, + ENTRY_DELETE_BTN, + FIELD_INPUT, + LOADING_SPINNER, + EXCEPTION_ITEM_CONTAINER, + ADD_EXCEPTIONS_BTN, +} from '../../screens/exceptions'; + +import { DETECTIONS_URL } from '../../urls/navigation'; +import { cleanKibana } from '../../tasks/common'; + +// NOTE: You might look at these tests and feel they're overkill, +// but the exceptions modal has a lot of logic making it difficult +// to test in enzyme and very small changes can inadvertently add +// bugs. As the complexity within the builder grows, these should +// ensure the most basic logic holds. +describe('Exceptions modal', () => { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsIndexToBeCreated(); + createCustomRule(newRule); + goToManageAlertsDetectionRules(); + goToRuleDetails(); + + cy.get(RULE_STATUS).should('have.text', '—'); + + // this is a made-up index that has just the necessary + // mappings to conduct tests, avoiding loading large + // amounts of data like in auditbeat_exceptions + esArchiverLoad('exceptions'); + + goToExceptionsTab(); + }); + + after(() => { + esArchiverUnload('exceptions'); + }); + + it('Does not overwrite values and-ed together', () => { + cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); + + // add multiple entries with invalid field values + addExceptionEntryFieldValue('agent.name', 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValue('@timestamp', 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValue('c', 2); + + // delete second item, invalid values 'a' and 'c' should remain + cy.get(ENTRY_DELETE_BTN).eq(1).click(); + cy.get(FIELD_INPUT).eq(0).should('have.text', 'agent.name'); + cy.get(FIELD_INPUT).eq(1).should('have.text', 'c'); + + closeExceptionBuilderModal(); + }); + + it('Does not overwrite values or-ed together', () => { + cy.get(ADD_EXCEPTIONS_BTN).click({ force: true }); + + // exception item 1 + addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.id.keyword', 0, 1); + + // exception item 2 + cy.get(ADD_OR_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.first', 1, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.last', 1, 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('e', 1, 2); + + // delete single entry from exception item 2 + cy.get(ENTRY_DELETE_BTN).eq(3).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', 'user.id.keyword'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'user.first'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'e'); + + // delete remaining entries in exception item 2 + cy.get(ENTRY_DELETE_BTN).eq(2).click(); + cy.get(ENTRY_DELETE_BTN).eq(2).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', 'user.id.keyword'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).should('not.exist'); + + closeExceptionBuilderModal(); + }); + + it('Does not overwrite values of nested entry items', () => { + openExceptionModalFromRuleSettings(); + cy.get(LOADING_SPINNER).should('not.exist'); + + // exception item 1 + addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('b', 0, 1); + + // exception item 2 with nested field + cy.get(ADD_OR_BTN).click(); + addExceptionEntryFieldValueOfItemX('c', 1, 0); + cy.get(ADD_NESTED_BTN).click(); + addExceptionEntryFieldValueOfItemX('user.id{downarrow}{enter}', 1, 1); + cy.get(ADD_AND_BTN).click(); + addExceptionEntryFieldValueOfItemX('last{downarrow}{enter}', 1, 3); + // This button will now read `Add non-nested button` + cy.get(ADD_NESTED_BTN).click(); + addExceptionEntryFieldValueOfItemX('@timestamp', 1, 4); + + // should have only deleted `user.id` + cy.get(ENTRY_DELETE_BTN).eq(4).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'user'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(2).should('have.text', 'last'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(3) + .should('have.text', '@timestamp'); + + // deleting the last value of a nested entry, should delete the child and parent + cy.get(ENTRY_DELETE_BTN).eq(4).click(); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(1) + .should('have.text', '@timestamp'); + + closeExceptionBuilderModal(); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 68baad7d3d259..30365c9bd4c70 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -17,6 +17,12 @@ export const DELETE_RULE_ACTION_BTN = '[data-test-subj="deleteRuleAction"]'; export const EDIT_RULE_ACTION_BTN = '[data-test-subj="editRuleAction"]'; +export const DUPLICATE_RULE_ACTION_BTN = '[data-test-subj="duplicateRuleAction"]'; + +export const DUPLICATE_RULE_MENU_PANEL_BTN = '[data-test-subj="rules-details-duplicate-rule"]'; + +export const REFRESH_BTN = '[data-test-subj="refreshRulesAction"] button'; + export const DELETE_RULE_BULK_BTN = '[data-test-subj="deleteRuleBulk"]'; export const ELASTIC_RULES_BTN = '[data-test-subj="showElasticRulesFilterButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index 7cd273b1db746..2479b76cf1de4 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -24,6 +24,20 @@ export const OPERATOR_INPUT = '[data-test-subj="operatorAutocompleteComboBox"]'; export const VALUES_INPUT = '[data-test-subj="valuesAutocompleteMatch"] [data-test-subj="comboBoxInput"]'; +export const ADD_AND_BTN = '[data-test-subj="exceptionsAndButton"]'; + +export const ADD_OR_BTN = '[data-test-subj="exceptionsOrButton"]'; + +export const ADD_NESTED_BTN = '[data-test-subj="exceptionsNestedButton"]'; + +export const ENTRY_DELETE_BTN = '[data-test-subj="builderItemEntryDeleteButton"]'; + +export const FIELD_INPUT_LIST_BTN = '[data-test-subj="comboBoxToggleListButton"]'; + +export const CANCEL_BTN = '[data-test-subj="cancelExceptionAddButton"]'; + +export const BUILDER_MODAL_BODY = '[data-test-subj="exceptionsBuilderWrapper"]'; + export const EXCEPTIONS_TABLE_TAB = '[data-test-subj="allRulesTableTab-exceptions"]'; export const EXCEPTIONS_TABLE = '[data-test-subj="exceptions-table"]'; @@ -43,3 +57,5 @@ export const EXCEPTIONS_TABLE_LIST_NAME = '[data-test-subj="exceptionsTableName" export const EXCEPTIONS_TABLE_MODAL = '[data-test-subj="referenceErrorModal"]'; export const EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN = '[data-test-subj="confirmModalConfirmButton"]'; + +export const EXCEPTION_ITEM_CONTAINER = '[data-test-subj="exceptionEntriesContainer"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 3553889449e6d..10644e046a68b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -31,6 +31,8 @@ import { RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE, rowsPerPageSelector, pageSelector, + DUPLICATE_RULE_ACTION_BTN, + DUPLICATE_RULE_MENU_PANEL_BTN, } from '../screens/alerts_detection_rules'; import { ALL_ACTIONS, DELETE_RULE } from '../screens/rule_details'; @@ -45,6 +47,33 @@ export const editFirstRule = () => { cy.get(EDIT_RULE_ACTION_BTN).click(); }; +export const duplicateFirstRule = () => { + cy.get(COLLAPSED_ACTION_BTN).should('be.visible'); + cy.get(COLLAPSED_ACTION_BTN).first().click({ force: true }); + cy.get(DUPLICATE_RULE_ACTION_BTN).should('be.visible'); + cy.get(DUPLICATE_RULE_ACTION_BTN).click(); +}; + +/** + * Duplicates the rule from the menu and does additional + * pipes and checking that the elements are present on the + * page as well as removed when doing the clicks to help reduce + * flake. + */ +export const duplicateRuleFromMenu = () => { + cy.get(ALL_ACTIONS).should('be.visible'); + cy.root() + .pipe(($el) => { + $el.find(ALL_ACTIONS).trigger('click'); + return $el.find(DUPLICATE_RULE_MENU_PANEL_BTN); + }) + .should(($el) => expect($el).to.be.visible); + // Because of a fade effect and fast clicking this can produce more than one click + cy.get(DUPLICATE_RULE_MENU_PANEL_BTN) + .pipe(($el) => $el.trigger('click')) + .should('not.be.visible'); +}; + export const deleteFirstRule = () => { cy.get(COLLAPSED_ACTION_BTN).first().click({ force: true }); cy.get(DELETE_RULE_ACTION_BTN).click(); @@ -87,9 +116,19 @@ export const reloadDeletedRules = () => { cy.get(RELOAD_PREBUILT_RULES_BTN).click({ force: true }); }; +/** + * Selects the number of rules. Since there can be missing click handlers + * when the page loads at first, we use a pipe and a trigger of click + * on it and then check to ensure that it is checked before continuing + * with the tests. + * @param numberOfRules The number of rules to click/check + */ export const selectNumberOfRules = (numberOfRules: number) => { for (let i = 0; i < numberOfRules; i++) { - cy.get(RULE_CHECKBOX).eq(i).click({ force: true }); + cy.get(RULE_CHECKBOX) + .eq(i) + .pipe(($el) => $el.trigger('click')) + .should('be.checked'); } }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index ab6063f5809c4..99f5bd9c20230 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CustomRule } from '../../objects/rule'; +import { CustomRule, ThreatIndicatorRule } from '../../objects/rule'; export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing') => cy.request({ @@ -29,6 +29,44 @@ export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing') => failOnStatusCode: false, }); +export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'rule_testing') => + cy.request({ + method: 'POST', + url: 'api/detection_engine/rules', + body: { + rule_id: ruleId, + risk_score: parseInt(rule.riskScore, 10), + description: rule.description, + interval: '10s', + name: rule.name, + severity: rule.severity.toLocaleLowerCase(), + type: 'threat_match', + threat_mapping: [ + { + entries: [ + { + field: rule.indicatorMapping, + type: 'mapping', + value: rule.indicatorMapping, + }, + ], + }, + ], + threat_query: '*:*', + threat_language: 'kuery', + threat_filters: [], + threat_index: ['mock*'], + threat_indicator_path: '', + from: 'now-17520h', + index: ['exceptions-*'], + query: rule.customQuery || '*:*', + language: 'kuery', + enabled: false, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); + export const createCustomRuleActivated = (rule: CustomRule, ruleId = '1') => cy.request({ method: 'POST', diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts new file mode 100644 index 0000000000000..97e93ef8194a4 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Exception } from '../objects/exception'; +import { + FIELD_INPUT, + OPERATOR_INPUT, + VALUES_INPUT, + CANCEL_BTN, + BUILDER_MODAL_BODY, + EXCEPTION_ITEM_CONTAINER, +} from '../screens/exceptions'; + +export const addExceptionEntryFieldValueOfItemX = ( + field: string, + itemIndex = 0, + fieldIndex = 0 +) => { + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(itemIndex) + .find(FIELD_INPUT) + .eq(fieldIndex) + .type(`${field}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryFieldValue = (field: string, index = 0) => { + cy.get(FIELD_INPUT).eq(index).type(`${field}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryOperatorValue = (operator: string, index = 0) => { + cy.get(OPERATOR_INPUT).eq(index).type(`${operator}{enter}`); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntryValue = (values: string[], index = 0) => { + values.forEach((value) => { + cy.get(VALUES_INPUT).eq(index).type(`${value}{enter}`); + }); + cy.get(BUILDER_MODAL_BODY).click(); +}; + +export const addExceptionEntry = (exception: Exception, index = 0) => { + addExceptionEntryFieldValue(exception.field, index); + addExceptionEntryOperatorValue(exception.operator, index); + addExceptionEntryValue(exception.values, index); +}; + +export const addNestedExceptionEntry = (exception: Exception, index = 0) => { + addExceptionEntryFieldValue(exception.field, index); + addExceptionEntryOperatorValue(exception.operator, index); + addExceptionEntryValue(exception.values, index); +}; + +export const closeExceptionBuilderModal = () => { + cy.get(CANCEL_BTN).click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 57037e9f269b4..411f326a0ace6 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -54,6 +54,12 @@ export const addsException = (exception: Exception) => { cy.get(CONFIRM_BTN).should('not.exist'); }; +export const openExceptionModalFromRuleSettings = () => { + cy.get(ADD_EXCEPTIONS_BTN).click(); + cy.get(LOADING_SPINNER).should('not.exist'); + cy.get(FIELD_INPUT).should('be.visible'); +}; + export const addsExceptionFromRuleSettings = (exception: Exception) => { cy.get(ADD_EXCEPTIONS_BTN).click(); cy.get(LOADING_SPINNER).should('exist'); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx index b7a8a45edce5e..03000e8916617 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx @@ -35,19 +35,20 @@ export const useGetFieldsByIssueType = ({ }: Props): UseGetFieldsByIssueType => { const [isLoading, setIsLoading] = useState(true); const [fields, setFields] = useState({}); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector || !issueType) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getFieldsByIssueType({ http, signal: abortCtrl.current.signal, @@ -55,7 +56,7 @@ export const useGetFieldsByIssueType = ({ id: issueType, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setFields(res.data ?? {}); if (res.status && res.status === 'error') { @@ -66,22 +67,24 @@ export const useGetFieldsByIssueType = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.FIELDS_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, issueType, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx index 4b60a9840c82b..3c35d315a2bcd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx @@ -35,27 +35,27 @@ export const useGetIssueTypes = ({ }: Props): UseGetIssueTypes => { const [isLoading, setIsLoading] = useState(true); const [issueTypes, setIssueTypes] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIssueTypes({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); const asOptions = (res.data ?? []).map((type) => ({ text: type.name ?? '', @@ -71,25 +71,29 @@ export const useGetIssueTypes = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.ISSUE_TYPES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; - }, [http, connector, toastNotifications, handleIssueType]); + // handleIssueType unmounts the component at init causing the request to be aborted + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [http, connector, toastNotifications]); return { issueTypes, diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx index 170cf2b53395e..b44b0558f1536 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx @@ -36,20 +36,20 @@ export const useGetIssues = ({ }: Props): UseGetIssues => { const [isLoading, setIsLoading] = useState(false); const [issues, setIssues] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = debounce(500, async () => { if (!actionConnector || isEmpty(query)) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIssues({ http, signal: abortCtrl.current.signal, @@ -57,7 +57,7 @@ export const useGetIssues = ({ title: query ?? '', }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIssues(res.data ?? []); if (res.status && res.status === 'error') { @@ -68,22 +68,24 @@ export const useGetIssues = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.ISSUES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: error.message, + }); + } } } }); + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, actionConnector, toastNotifications, query]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx index 89b42b1a88c1e..6c70286426168 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx @@ -35,10 +35,10 @@ export const useGetSingleIssue = ({ }: Props): UseGetSingleIssue => { const [isLoading, setIsLoading] = useState(false); const [issue, setIssue] = useState(null); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!actionConnector || !id) { setIsLoading(false); @@ -55,7 +55,7 @@ export const useGetSingleIssue = ({ id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIssue(res.data ?? null); if (res.status && res.status === 'error') { @@ -66,22 +66,24 @@ export const useGetSingleIssue = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.GET_ISSUE_API_ERROR(id), - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, actionConnector, id, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx index 99964f466058f..34cbb0a69b0f4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx @@ -34,27 +34,27 @@ export const useGetIncidentTypes = ({ }: Props): UseGetIncidentTypes => { const [isLoading, setIsLoading] = useState(true); const [incidentTypes, setIncidentTypes] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIncidentTypes({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIncidentTypes(res.data ?? []); if (res.status && res.status === 'error') { @@ -65,22 +65,24 @@ export const useGetIncidentTypes = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.INCIDENT_TYPES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.INCIDENT_TYPES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx index 0a71891ae41b2..5b44c6b4a32b2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx @@ -7,9 +7,9 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; import { getSeverity } from './api'; import * as i18n from './translations'; -import { ActionConnector } from '../../../containers/types'; type Severity = Array<{ id: number; name: string }>; @@ -31,26 +31,26 @@ export const useGetSeverity = ({ http, toastNotifications, connector }: Props): const [isLoading, setIsLoading] = useState(true); const [severity, setSeverity] = useState([]); const abortCtrl = useRef(new AbortController()); + const didCancel = useRef(false); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getSeverity({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setSeverity(res.data ?? []); @@ -62,22 +62,24 @@ export const useGetSeverity = ({ http, toastNotifications, connector }: Props): } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.SEVERITY_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.SEVERITY_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts index 0867dc41eeb78..77c263385df0a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts @@ -87,7 +87,7 @@ export const PRIORITY = i18n.translate( export const ALERT_FIELDS_LABEL = i18n.translate( 'xpack.securitySolution.components.connectors.serviceNow.alertFieldsTitle', { - defaultMessage: 'Fields associated with alerts', + defaultMessage: 'Select Observables to push', } ); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx index 16e905bdabfee..a979f96d84ab2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx @@ -37,20 +37,20 @@ export const useGetChoices = ({ }: UseGetChoicesProps): UseGetChoices => { const [isLoading, setIsLoading] = useState(false); const [choices, setChoices] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getChoices({ http, signal: abortCtrl.current.signal, @@ -58,7 +58,7 @@ export const useGetChoices = ({ fields, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setChoices(res.data ?? []); if (res.status && res.status === 'error') { @@ -71,22 +71,24 @@ export const useGetChoices = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.CHOICES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx index ff5762b8476de..3590fffdef5b2 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx @@ -22,25 +22,25 @@ export const useActionTypes = (): UseActionTypesResponse => { const [, dispatchToaster] = useStateToaster(); const [loading, setLoading] = useState(true); const [actionTypes, setActionTypes] = useState([]); - const didCancel = useRef(false); - const abortCtrl = useRef(new AbortController()); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const queryFirstTime = useRef(true); const refetchActionTypes = useCallback(async () => { try { setLoading(true); - didCancel.current = false; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); - const res = await fetchActionTypes({ signal: abortCtrl.current.signal }); + const res = await fetchActionTypes({ signal: abortCtrlRef.current.signal }); - if (!didCancel.current) { + if (!isCancelledRef.current) { setLoading(false); setActionTypes(res); } } catch (error) { - if (!didCancel.current) { + if (!isCancelledRef.current) { setLoading(false); setActionTypes([]); errorToToaster({ @@ -59,8 +59,8 @@ export const useActionTypes = (): UseActionTypesResponse => { } return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); queryFirstTime.current = true; }; }, [refetchActionTypes]); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx index cc8c93fc990eb..21d1832796ba8 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useEffect, useCallback, useReducer } from 'react'; +import { useEffect, useCallback, useReducer, useRef } from 'react'; import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; import { @@ -207,129 +207,128 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }, []); const [, dispatchToaster] = useStateToaster(); + const isCancelledRefetchRef = useRef(false); + const abortCtrlRefetchRef = useRef(new AbortController()); - const refetchCaseConfigure = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); + const isCancelledPersistRef = useRef(false); + const abortCtrlPersistRef = useRef(new AbortController()); - const fetchCaseConfiguration = async () => { - try { - setLoading(true); - const res = await getCaseConfigure({ signal: abortCtrl.signal }); - if (!didCancel) { - if (res != null) { - setConnector(res.connector); - if (setClosureType != null) { - setClosureType(res.closureType); - } - setVersion(res.version); - setMappings(res.mappings); + const refetchCaseConfigure = useCallback(async () => { + try { + isCancelledRefetchRef.current = false; + abortCtrlRefetchRef.current.abort(); + abortCtrlRefetchRef.current = new AbortController(); - if (!state.firstLoad) { - setFirstLoad(true); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, - }, - }); - } - } - if (res.error != null) { - errorToToaster({ - dispatchToaster, - error: new Error(res.error), - title: i18n.ERROR_TITLE, + setLoading(true); + const res = await getCaseConfigure({ signal: abortCtrlRefetchRef.current.signal }); + + if (!isCancelledRefetchRef.current) { + if (res != null) { + setConnector(res.connector); + if (setClosureType != null) { + setClosureType(res.closureType); + } + setVersion(res.version); + setMappings(res.mappings); + + if (!state.firstLoad) { + setFirstLoad(true); + if (setCurrentConfiguration != null) { + setCurrentConfiguration({ + closureType: res.closureType, + connector: { + ...res.connector, + }, }); } } - setLoading(false); + if (res.error != null) { + errorToToaster({ + dispatchToaster, + error: new Error(res.error), + title: i18n.ERROR_TITLE, + }); + } } - } catch (error) { - if (!didCancel) { - setLoading(false); + setLoading(false); + } + } catch (error) { + if (!isCancelledRefetchRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ dispatchToaster, error: error.body && error.body.message ? new Error(error.body.message) : error, title: i18n.ERROR_TITLE, }); } + setLoading(false); } - }; - - fetchCaseConfiguration(); - - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.firstLoad]); const persistCaseConfigure = useCallback( async ({ connector, closureType }: ConnectorConfiguration) => { - let didCancel = false; - const abortCtrl = new AbortController(); - const saveCaseConfiguration = async () => { - try { - setPersistLoading(true); - const connectorObj = { - connector, - closure_type: closureType, - }; - const res = - state.version.length === 0 - ? await postCaseConfigure(connectorObj, abortCtrl.signal) - : await patchCaseConfigure( - { - ...connectorObj, - version: state.version, - }, - abortCtrl.signal - ); - if (!didCancel) { - setConnector(res.connector); - if (setClosureType) { - setClosureType(res.closureType); - } - setVersion(res.version); - setMappings(res.mappings); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, + try { + isCancelledPersistRef.current = false; + abortCtrlPersistRef.current.abort(); + abortCtrlPersistRef.current = new AbortController(); + setPersistLoading(true); + + const connectorObj = { + connector, + closure_type: closureType, + }; + + const res = + state.version.length === 0 + ? await postCaseConfigure(connectorObj, abortCtrlPersistRef.current.signal) + : await patchCaseConfigure( + { + ...connectorObj, + version: state.version, }, - }); - } - if (res.error != null) { - errorToToaster({ - dispatchToaster, - error: new Error(res.error), - title: i18n.ERROR_TITLE, - }); - } - displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); - setPersistLoading(false); + abortCtrlPersistRef.current.signal + ); + + if (!isCancelledPersistRef.current) { + setConnector(res.connector); + if (setClosureType) { + setClosureType(res.closureType); + } + setVersion(res.version); + setMappings(res.mappings); + if (setCurrentConfiguration != null) { + setCurrentConfiguration({ + closureType: res.closureType, + connector: { + ...res.connector, + }, + }); } - } catch (error) { - if (!didCancel) { - setConnector(state.currentConfiguration.connector); - setPersistLoading(false); + if (res.error != null) { + errorToToaster({ + dispatchToaster, + error: new Error(res.error), + title: i18n.ERROR_TITLE, + }); + } + displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); + setPersistLoading(false); + } + } catch (error) { + if (!isCancelledPersistRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); } + setConnector(state.currentConfiguration.connector); + setPersistLoading(false); } - }; - saveCaseConfiguration(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } }, [ dispatchToaster, @@ -345,6 +344,12 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { useEffect(() => { refetchCaseConfigure(); + return () => { + isCancelledRefetchRef.current = true; + abortCtrlRefetchRef.current.abort(); + isCancelledPersistRef.current = true; + abortCtrlPersistRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx index d21e50902ca83..338d04f702c63 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; import * as i18n from '../translations'; @@ -22,40 +22,45 @@ export const useConnectors = (): UseConnectorsResponse => { const [, dispatchToaster] = useStateToaster(); const [loading, setLoading] = useState(true); const [connectors, setConnectors] = useState([]); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const refetchConnectors = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const getConnectors = async () => { - try { - setLoading(true); - const res = await fetchConnectors({ signal: abortCtrl.signal }); - if (!didCancel) { - setLoading(false); - setConnectors(res); - } - } catch (error) { - if (!didCancel) { - setLoading(false); - setConnectors([]); + const refetchConnectors = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + setLoading(true); + const res = await fetchConnectors({ signal: abortCtrlRef.current.signal }); + + if (!isCancelledRef.current) { + setLoading(false); + setConnectors(res); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); } + + setLoading(false); + setConnectors([]); } - }; - getConnectors(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { refetchConnectors(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx index 0fe45aaab799b..da069ee6f1075 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useReducer } from 'react'; +import { useCallback, useReducer, useRef, useEffect } from 'react'; import { CaseStatuses } from '../../../../case/common/api'; import { displaySuccessToast, @@ -87,49 +87,45 @@ export const useUpdateCases = (): UseUpdateCases => { isUpdated: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const dispatchUpdateCases = useCallback((cases: BulkUpdateStatus[], action: string) => { - let cancel = false; - const abortCtrl = new AbortController(); + const dispatchUpdateCases = useCallback(async (cases: BulkUpdateStatus[], action: string) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); - const patchData = async () => { - try { - dispatch({ type: 'FETCH_INIT' }); - const patchResponse = await patchCasesStatus(cases, abortCtrl.signal); - if (!cancel) { - const resultCount = Object.keys(patchResponse).length; - const firstTitle = patchResponse[0].title; + dispatch({ type: 'FETCH_INIT' }); + const patchResponse = await patchCasesStatus(cases, abortCtrlRef.current.signal); - dispatch({ type: 'FETCH_SUCCESS', payload: true }); + if (!isCancelledRef.current) { + const resultCount = Object.keys(patchResponse).length; + const firstTitle = patchResponse[0].title; - const messageArgs = { - totalCases: resultCount, - caseTitle: resultCount === 1 ? firstTitle : '', - }; + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + const messageArgs = { + totalCases: resultCount, + caseTitle: resultCount === 1 ? firstTitle : '', + }; - const message = - action === 'status' - ? getStatusToasterMessage(patchResponse[0].status, messageArgs) - : ''; + const message = + action === 'status' ? getStatusToasterMessage(patchResponse[0].status, messageArgs) : ''; - displaySuccessToast(message, dispatchToaster); - } - } catch (error) { - if (!cancel) { + displaySuccessToast(message, dispatchToaster); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - patchData(); - return () => { - cancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -137,14 +133,25 @@ export const useUpdateCases = (): UseUpdateCases => { dispatch({ type: 'RESET_IS_UPDATED' }); }, []); - const updateBulkStatus = useCallback((cases: Case[], status: string) => { - const updateCasesStatus: BulkUpdateStatus[] = cases.map((theCase) => ({ - status, - id: theCase.id, - version: theCase.version, - })); - dispatchUpdateCases(updateCasesStatus, 'status'); + const updateBulkStatus = useCallback( + (cases: Case[], status: string) => { + const updateCasesStatus: BulkUpdateStatus[] = cases.map((theCase) => ({ + status, + id: theCase.id, + version: theCase.version, + })); + dispatchUpdateCases(updateCasesStatus, 'status'); + }, // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; }, []); + return { ...state, updateBulkStatus, dispatchResetIsUpdated }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx index 923c20dcf8ebd..f3d59a2883f2a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useReducer } from 'react'; +import { useCallback, useReducer, useRef, useEffect } from 'react'; import { displaySuccessToast, errorToToaster, @@ -78,45 +78,43 @@ export const useDeleteCases = (): UseDeleteCase => { isDeleted: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const dispatchDeleteCases = useCallback((cases: DeleteCase[]) => { - let cancel = false; - const abortCtrl = new AbortController(); + const dispatchDeleteCases = useCallback(async (cases: DeleteCase[]) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT' }); - const deleteData = async () => { - try { - dispatch({ type: 'FETCH_INIT' }); - const caseIds = cases.map((theCase) => theCase.id); - // We don't allow user batch delete sub cases on UI at the moment. - if (cases[0].type != null || cases.length > 1) { - await deleteCases(caseIds, abortCtrl.signal); - } else { - await deleteSubCases(caseIds, abortCtrl.signal); - } + const caseIds = cases.map((theCase) => theCase.id); + // We don't allow user batch delete sub cases on UI at the moment. + if (cases[0].type != null || cases.length > 1) { + await deleteCases(caseIds, abortCtrlRef.current.signal); + } else { + await deleteSubCases(caseIds, abortCtrlRef.current.signal); + } - if (!cancel) { - dispatch({ type: 'FETCH_SUCCESS', payload: true }); - displaySuccessToast( - i18n.DELETED_CASES(cases.length, cases.length === 1 ? cases[0].title : ''), - dispatchToaster - ); - } - } catch (error) { - if (!cancel) { + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + displaySuccessToast( + i18n.DELETED_CASES(cases.length, cases.length === 1 ? cases[0].title : ''), + dispatchToaster + ); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_DELETING, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - deleteData(); - return () => { - abortCtrl.abort(); - cancel = true; - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -142,5 +140,12 @@ export const useDeleteCases = (): UseDeleteCase => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.isDisplayConfirmDeleteModal]); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, dispatchResetIsDeleted, handleOnDeleteConfirm, handleToggleModal }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx index 9b536f32e7eb8..9b10247794c8d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getActionLicense } from './api'; @@ -28,53 +28,58 @@ const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState(initialData); - const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchActionLicense = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchActionLicense = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setActionLicensesState({ - ...actionLicenseState, + ...initialData, isLoading: true, }); - try { - const response = await getActionLicense(abortCtrl.signal); - if (!didCancel) { - setActionLicensesState({ - actionLicense: - response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getActionLicense(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + setActionLicensesState({ + actionLicense: response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setActionLicensesState({ - actionLicense: null, - isLoading: false, - isError: true, - }); } + + setActionLicensesState({ + actionLicense: null, + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionLicenseState]); useEffect(() => { fetchActionLicense(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return { ...actionLicenseState }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 1c4476e3cb2b7..fb8da8d0663ee 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { isEmpty } from 'lodash'; import { useEffect, useReducer, useCallback, useRef } from 'react'; import { CaseStatuses, CaseType } from '../../../../case/common/api'; @@ -96,48 +95,48 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { data: initialData, }); const [, dispatchToaster] = useStateToaster(); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const updateCase = useCallback((newCase: Case) => { dispatch({ type: 'UPDATE_CASE', payload: newCase }); }, []); const callFetch = useCallback(async () => { - const fetchData = async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - try { - const response = await (subCaseId - ? getSubCase(caseId, subCaseId, true, abortCtrl.current.signal) - : getCase(caseId, true, abortCtrl.current.signal)); - if (!didCancel.current) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); - } - } catch (error) { - if (!didCancel.current) { + + const response = await (subCaseId + ? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal) + : getCase(caseId, true, abortCtrlRef.current.signal)); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - didCancel.current = false; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); - fetchData(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); useEffect(() => { - if (!isEmpty(caseId)) { - callFetch(); - } + callFetch(); + return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx index 12e5f6643351f..cc8deaf72eef6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx @@ -6,7 +6,7 @@ */ import { isEmpty, uniqBy } from 'lodash/fp'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; @@ -244,64 +244,67 @@ export const useGetCaseUserActions = ( const [caseUserActionsState, setCaseUserActionsState] = useState( initialData ); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + const isCancelledRef = useRef(false); const [, dispatchToaster] = useStateToaster(); const fetchCaseUserActions = useCallback( - (thisCaseId: string, thisSubCaseId?: string) => { - const fetchData = async () => { - try { + async (thisCaseId: string, thisSubCaseId?: string) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + + const response = await (thisSubCaseId + ? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrlRef.current.signal) + : getCaseUserActions(thisCaseId, abortCtrlRef.current.signal)); + + if (!isCancelledRef.current) { + // Attention Future developer + // We are removing the first item because it will always be the creation of the case + // and we do not want it to simplify our life + const participants = !isEmpty(response) + ? uniqBy('actionBy.username', response).map((cau) => cau.actionBy) + : []; + + const caseUserActions = !isEmpty(response) + ? thisSubCaseId + ? response + : response.slice(1) + : []; + setCaseUserActionsState({ - ...caseUserActionsState, - isLoading: true, + caseUserActions, + ...getPushedInfo(caseUserActions, caseConnectorId), + isLoading: false, + isError: false, + participants, }); - - const response = await (thisSubCaseId - ? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrl.current.signal) - : getCaseUserActions(thisCaseId, abortCtrl.current.signal)); - if (!didCancel.current) { - // Attention Future developer - // We are removing the first item because it will always be the creation of the case - // and we do not want it to simplify our life - const participants = !isEmpty(response) - ? uniqBy('actionBy.username', response).map((cau) => cau.actionBy) - : []; - - const caseUserActions = !isEmpty(response) - ? thisSubCaseId - ? response - : response.slice(1) - : []; - setCaseUserActionsState({ - caseUserActions, - ...getPushedInfo(caseUserActions, caseConnectorId), - isLoading: false, - isError: false, - participants, - }); - } - } catch (error) { - if (!didCancel.current) { + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setCaseUserActionsState({ - caseServices: {}, - caseUserActions: [], - hasDataToPush: false, - isError: true, - isLoading: false, - participants: [], - }); } + + setCaseUserActionsState({ + caseServices: {}, + caseUserActions: [], + hasDataToPush: false, + isError: true, + isLoading: false, + participants: [], + }); } - }; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); - fetchData(); + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [caseConnectorId] @@ -313,8 +316,8 @@ export const useGetCaseUserActions = ( } return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx index 298d817fffa88..c83cc02dedb97 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useReducer } from 'react'; +import { useCallback, useEffect, useReducer, useRef } from 'react'; import { CaseStatuses } from '../../../../case/common/api'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case, UpdateByKey } from './types'; @@ -139,6 +139,10 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { selectedCases: [], }); const [, dispatchToaster] = useStateToaster(); + const didCancelFetchCases = useRef(false); + const didCancelUpdateCases = useRef(false); + const abortCtrlFetchCases = useRef(new AbortController()); + const abortCtrlUpdateCases = useRef(new AbortController()); const setSelectedCases = useCallback((mySelectedCases: Case[]) => { dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases }); @@ -152,81 +156,69 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters }); }, []); - const fetchCases = useCallback((filterOptions: FilterOptions, queryParams: QueryParams) => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + const fetchCases = useCallback(async (filterOptions: FilterOptions, queryParams: QueryParams) => { + try { + didCancelFetchCases.current = false; + abortCtrlFetchCases.current.abort(); + abortCtrlFetchCases.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: 'cases' }); - try { - const response = await getCases({ - filterOptions, - queryParams, - signal: abortCtrl.signal, + + const response = await getCases({ + filterOptions, + queryParams, + signal: abortCtrlFetchCases.current.signal, + }); + + if (!didCancelFetchCases.current) { + dispatch({ + type: 'FETCH_CASES_SUCCESS', + payload: response, }); - if (!didCancel) { - dispatch({ - type: 'FETCH_CASES_SUCCESS', - payload: response, - }); - } - } catch (error) { - if (!didCancel) { + } + } catch (error) { + if (!didCancelFetchCases.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); } + dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => fetchCases(state.filterOptions, state.queryParams), [ - state.queryParams, - state.filterOptions, - ]); - const dispatchUpdateCaseProperty = useCallback( - ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + async ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { + try { + didCancelUpdateCases.current = false; + abortCtrlUpdateCases.current.abort(); + abortCtrlUpdateCases.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' }); - try { - await patchCase( - caseId, - { [updateKey]: updateValue }, - // saved object versions are typed as string | undefined, hope that's not true - version ?? '', - abortCtrl.signal - ); - if (!didCancel) { - dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); - fetchCases(state.filterOptions, state.queryParams); - refetchCasesStatus(); - } - } catch (error) { - if (!didCancel) { + + await patchCase( + caseId, + { [updateKey]: updateValue }, + // saved object versions are typed as string | undefined, hope that's not true + version ?? '', + abortCtrlUpdateCases.current.signal + ); + + if (!didCancelUpdateCases.current) { + dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); + fetchCases(state.filterOptions, state.queryParams); + refetchCasesStatus(); + } + } catch (error) { + if (!didCancelUpdateCases.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); } + dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [state.filterOptions, state.queryParams] @@ -237,6 +229,17 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.filterOptions, state.queryParams]); + useEffect(() => { + fetchCases(state.filterOptions, state.queryParams); + return () => { + didCancelFetchCases.current = true; + didCancelUpdateCases.current = true; + abortCtrlFetchCases.current.abort(); + abortCtrlUpdateCases.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.queryParams, state.filterOptions]); + return { ...state, dispatchUpdateCaseProperty, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx index 057fc05008bb0..087f7ef455cba 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getCasesStatus } from './api'; @@ -32,51 +32,56 @@ export interface UseGetCasesStatus extends CasesStatusState { export const useGetCasesStatus = (): UseGetCasesStatus => { const [casesStatusState, setCasesStatusState] = useState(initialData); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchCasesStatus = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchCasesStatus = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setCasesStatusState({ - ...casesStatusState, + ...initialData, isLoading: true, }); - try { - const response = await getCasesStatus(abortCtrl.signal); - if (!didCancel) { - setCasesStatusState({ - ...response, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getCasesStatus(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + setCasesStatusState({ + ...response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setCasesStatusState({ - countClosedCases: 0, - countInProgressCases: 0, - countOpenCases: 0, - isLoading: false, - isError: true, - }); } + setCasesStatusState({ + countClosedCases: 0, + countInProgressCases: 0, + countOpenCases: 0, + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [casesStatusState]); + }, []); useEffect(() => { fetchCasesStatus(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx index 25c483045b84f..f2c33ec4730fe 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; - +import { useCallback, useEffect, useState, useRef } from 'react'; import { isEmpty } from 'lodash/fp'; + import { User } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getReporters } from './api'; @@ -35,57 +35,61 @@ export const useGetReporters = (): UseGetReporters => { const [reportersState, setReporterState] = useState(initialData); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchReporters = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchReporters = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setReporterState({ ...reportersState, isLoading: true, }); - try { - const response = await getReporters(abortCtrl.signal); - const myReporters = response - .map((r) => - r.full_name == null || isEmpty(r.full_name) ? r.username ?? '' : r.full_name - ) - .filter((u) => !isEmpty(u)); - if (!didCancel) { - setReporterState({ - reporters: myReporters, - respReporters: response, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getReporters(abortCtrlRef.current.signal); + const myReporters = response + .map((r) => (r.full_name == null || isEmpty(r.full_name) ? r.username ?? '' : r.full_name)) + .filter((u) => !isEmpty(u)); + + if (!isCancelledRef.current) { + setReporterState({ + reporters: myReporters, + respReporters: response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setReporterState({ - reporters: [], - respReporters: [], - isLoading: false, - isError: true, - }); } + + setReporterState({ + reporters: [], + respReporters: [], + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportersState]); useEffect(() => { fetchReporters(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return { ...reportersState, fetchReporters }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx index 208516d302eb4..4a7a298e2cd86 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { useEffect, useReducer } from 'react'; - +import { useEffect, useReducer, useRef, useCallback } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getTags } from './api'; import * as i18n from './translations'; @@ -59,37 +58,42 @@ export const useGetTags = (): UseGetTags => { tags: initialData, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const callFetch = () => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + const callFetch = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - try { - const response = await getTags(abortCtrl.signal); - if (!didCancel) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); - } - } catch (error) { - if (!didCancel) { + + const response = await getTags(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; - }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { callFetch(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { ...state, fetchTags: callFetch }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx index c4fa030473534..d890c050f5034 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx @@ -6,7 +6,6 @@ */ import { useReducer, useCallback, useRef, useEffect } from 'react'; - import { CasePostRequest } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { postCase } from './api'; @@ -51,38 +50,41 @@ export const usePostCase = (): UsePostCase => { isError: false, }); const [, dispatchToaster] = useStateToaster(); - const cancel = useRef(false); - const abortCtrl = useRef(new AbortController()); - const postMyCase = useCallback( - async (data: CasePostRequest) => { - try { - dispatch({ type: 'FETCH_INIT' }); - abortCtrl.current.abort(); - cancel.current = false; - abortCtrl.current = new AbortController(); - const response = await postCase(data, abortCtrl.current.signal); - if (!cancel.current) { - dispatch({ type: 'FETCH_SUCCESS' }); - } - return response; - } catch (error) { - if (!cancel.current) { + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const postMyCase = useCallback(async (data: CasePostRequest) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + dispatch({ type: 'FETCH_INIT' }); + const response = await postCase(data, abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS' }); + } + return response; + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }, - [dispatchToaster] - ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { return () => { - abortCtrl.current.abort(); - cancel.current = true; + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; }, []); return { ...state, postCase: postMyCase }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx index 8fc8053c14f70..5eb875287ba88 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; - +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { CommentRequest } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; @@ -58,38 +57,47 @@ export const usePostComment = (): UsePostComment => { isError: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const postMyComment = useCallback( async ({ caseId, data, updateCase, subCaseId }: PostComment) => { - let cancel = false; - const abortCtrl = new AbortController(); - try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - const response = await postComment(data, caseId, abortCtrl.signal, subCaseId); - if (!cancel) { + + const response = await postComment(data, caseId, abortCtrlRef.current.signal, subCaseId); + + if (!isCancelledRef.current) { dispatch({ type: 'FETCH_SUCCESS' }); if (updateCase) { updateCase(response); } } } catch (error) { - if (!cancel) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE' }); } } - return () => { - abortCtrl.abort(); - cancel = true; - }; }, [dispatchToaster] ); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, postComment: postMyComment }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx index 03d881d7934e9..27a02d9300cc0 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx @@ -67,17 +67,17 @@ export const usePostPushToService = (): UsePostPushToService => { }); const [, dispatchToaster] = useStateToaster(); const cancel = useRef(false); - const abortCtrl = useRef(new AbortController()); + const abortCtrlRef = useRef(new AbortController()); const pushCaseToExternalService = useCallback( async ({ caseId, connector }: PushToServiceRequest) => { try { - dispatch({ type: 'FETCH_INIT' }); - abortCtrl.current.abort(); + abortCtrlRef.current.abort(); cancel.current = false; - abortCtrl.current = new AbortController(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT' }); - const response = await pushCase(caseId, connector.id, abortCtrl.current.signal); + const response = await pushCase(caseId, connector.id, abortCtrlRef.current.signal); if (!cancel.current) { dispatch({ type: 'FETCH_SUCCESS' }); @@ -90,11 +90,13 @@ export const usePostPushToService = (): UsePostPushToService => { return response; } catch (error) { if (!cancel.current) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE' }); } } @@ -105,7 +107,7 @@ export const usePostPushToService = (): UsePostPushToService => { useEffect(() => { return () => { - abortCtrl.current.abort(); + abortCtrlRef.current.abort(); cancel.current = true; }; }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index 23a23caeb71bd..e8de2257009e6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import { useReducer, useCallback, useEffect, useRef } from 'react'; +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; - import { patchCase, patchSubCase } from './api'; import { UpdateKey, UpdateByKey, CaseStatuses } from './types'; import * as i18n from './translations'; @@ -70,8 +69,8 @@ export const useUpdateCase = ({ updateKey: null, }); const [, dispatchToaster] = useStateToaster(); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const dispatchUpdateCaseProperty = useCallback( async ({ @@ -84,24 +83,27 @@ export const useUpdateCase = ({ onError, }: UpdateByKey) => { try { - didCancel.current = false; - abortCtrl.current = new AbortController(); + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: updateKey }); + const response = await (updateKey === 'status' && subCaseId ? patchSubCase( caseId, subCaseId, { status: updateValue as CaseStatuses }, caseData.version, - abortCtrl.current.signal + abortCtrlRef.current.signal ) : patchCase( caseId, { [updateKey]: updateValue }, caseData.version, - abortCtrl.current.signal + abortCtrlRef.current.signal )); - if (!didCancel.current) { + + if (!isCancelledRef.current) { if (fetchCaseUserActions != null) { fetchCaseUserActions(caseId, subCaseId); } @@ -119,7 +121,7 @@ export const useUpdateCase = ({ } } } catch (error) { - if (!didCancel.current) { + if (!isCancelledRef.current) { if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, @@ -140,8 +142,8 @@ export const useUpdateCase = ({ useEffect(() => { return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx index e36b21823310e..81bce248852fe 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; - +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; - import { patchComment } from './api'; import * as i18n from './translations'; import { Case } from './types'; @@ -72,6 +70,8 @@ export const useUpdateComment = (): UseUpdateComment => { isError: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const dispatchUpdateComment = useCallback( async ({ @@ -83,41 +83,49 @@ export const useUpdateComment = (): UseUpdateComment => { updateCase, version, }: UpdateComment) => { - let cancel = false; - const abortCtrl = new AbortController(); try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: commentId }); + const response = await patchComment( caseId, commentId, commentUpdate, version, - abortCtrl.signal, + abortCtrlRef.current.signal, subCaseId ); - if (!cancel) { + + if (!isCancelledRef.current) { updateCase(response); fetchUserActions(); dispatch({ type: 'FETCH_SUCCESS', payload: { commentId } }); } } catch (error) { - if (!cancel) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE', payload: commentId }); } } - return () => { - cancel = true; - abortCtrl.abort(); - }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [] ); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, patchComment: dispatchUpdateComment }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index b083ecbafeb2c..3a2170d126a24 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -466,7 +466,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ )} {fetchOrCreateListError == null && ( - {i18n.CANCEL} + + {i18n.CANCEL} + { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { }).onChange([{ label: 'machine.os' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'machine.os', operator: 'included', type: 'match', value: '' }, + { id: '123', field: 'machine.os', operator: 'included', type: 'match', value: '' }, 0 ); }); @@ -445,6 +456,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onChange([{ label: 'is not' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, + { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, 0 ); }); @@ -480,6 +492,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' }, + { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' }, 0 ); }); @@ -515,6 +528,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, + { id: '123', field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, 0 ); }); @@ -550,6 +564,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { expect(mockOnChange).toHaveBeenCalledWith( { + id: '123', field: 'ip', operator: 'excluded', type: 'list', @@ -590,6 +606,7 @@ describe('BuilderEntryItem', () => { wrapper = mount( { wrapper = mount( = ({ ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }, [handleFieldChange, indexPattern, entry, listType] @@ -176,7 +180,11 @@ export const BuilderEntryItem: React.FC = ({ ); } else { - return comboBox; + return ( + + {comboBox} + + ); } }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx index 402496ef00f66..f9afa48408e39 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx @@ -111,35 +111,38 @@ export const BuilderExceptionListItemComponent = React.memo - {entries.map((item, index) => ( - - - {item.nested === 'child' && } - - { + const key = (item as typeof item & { id?: string }).id ?? `${index}`; + return ( + + + {item.nested === 'child' && } + + + + - - - - - ))} + + + ); + })}
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index ea948ab9b3b5c..8d0f042e7a498 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -28,7 +28,15 @@ import { } from '../../autocomplete/operators'; import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from '../types'; import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { Entry, EntryNested } from '../../../../lists_plugin_deps'; +import { + EntryMatch, + EntryMatchAny, + EntryNested, + EntryList, + EntryExists, + OperatorTypeEnum, + OperatorEnum, +} from '../../../../shared_imports'; import { getEntryFromOperator, @@ -46,6 +54,31 @@ import { getCorrespondingKeywordField, } from './helpers'; import { OperatorOption } from '../../autocomplete/types'; +import { ENTRIES_WITH_IDS } from '../../../../../../lists/common/constants.mock'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +const getEntryNestedWithIdMock = () => ({ + id: '123', + ...getEntryNestedMock(), +}); + +const getEntryExistsWithIdMock = () => ({ + id: '123', + ...getEntryExistsMock(), +}); + +const getEntryMatchWithIdMock = () => ({ + id: '123', + ...getEntryMatchMock(), +}); + +const getEntryMatchAnyWithIdMock = () => ({ + id: '123', + ...getEntryMatchAnyMock(), +}); const getMockIndexPattern = (): IIndexPattern => ({ id: '1234', @@ -54,6 +87,7 @@ const getMockIndexPattern = (): IIndexPattern => ({ }); const getMockBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: getField('ip'), operator: isOperator, value: 'some value', @@ -64,15 +98,16 @@ const getMockBuilderEntry = (): FormattedBuilderEntry => ({ }); const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: getField('nestedField.child'), operator: isOperator, value: 'some value', nested: 'child', parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }, parentIndex: 0, }, @@ -81,6 +116,7 @@ const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ }); const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ + id: '123', field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }, operator: isOperator, value: undefined, @@ -225,15 +261,16 @@ describe('Exception builder helpers', () => { test('it returns nested fields that match parent value when "item.nested" is "child"', () => { const payloadItem: FormattedBuilderEntry = { + id: '123', field: getEndpointField('file.Ext.code_signature.status'), operator: isOperator, value: 'some value', nested: 'child', parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'file.Ext.code_signature', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }, parentIndex: 0, }, @@ -351,7 +388,7 @@ describe('Exception builder helpers', () => { ], }; const payloadItem: BuilderEntry = { - ...getEntryMatchMock(), + ...getEntryMatchWithIdMock(), field: 'machine.os.raw.text', value: 'some os', }; @@ -363,6 +400,7 @@ describe('Exception builder helpers', () => { undefined ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { name: 'machine.os.raw.text', @@ -385,11 +423,11 @@ describe('Exception builder helpers', () => { test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'child' }; + const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; const payloadParent: EntryNested = { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }; const output = getFormattedBuilderEntry( payloadIndexPattern, @@ -399,6 +437,7 @@ describe('Exception builder helpers', () => { 1 ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { aggregatable: false, @@ -419,9 +458,10 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: { parent: { + id: '123', entries: [{ ...payloadItem }], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, parentIndex: 1, }, @@ -433,7 +473,11 @@ describe('Exception builder helpers', () => { test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'ip', + value: 'some ip', + }; const output = getFormattedBuilderEntry( payloadIndexPattern, payloadItem, @@ -442,6 +486,7 @@ describe('Exception builder helpers', () => { undefined ); const expected: FormattedBuilderEntry = { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -465,14 +510,14 @@ describe('Exception builder helpers', () => { describe('#isEntryNested', () => { test('it returns "false" if payload is not of type EntryNested', () => { - const payload: BuilderEntry = getEntryMatchMock(); + const payload: BuilderEntry = getEntryMatchWithIdMock(); const output = isEntryNested(payload); const expected = false; expect(output).toEqual(expected); }); test('it returns "true if payload is of type EntryNested', () => { - const payload: EntryNested = getEntryNestedMock(); + const payload: EntryNested = getEntryNestedWithIdMock(); const output = isEntryNested(payload); const expected = true; expect(output).toEqual(expected); @@ -482,10 +527,11 @@ describe('Exception builder helpers', () => { describe('#getFormattedBuilderEntries', () => { test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [getEntryMatchMock()]; + const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: undefined, nested: undefined, @@ -501,12 +547,13 @@ describe('Exception builder helpers', () => { test('it returns formatted entries when no nested entries exist', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, - { ...getEntryMatchAnyMock(), field: 'extension', value: ['some extension'] }, + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, ]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -525,6 +572,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 1, field: { aggregatable: true, @@ -549,18 +597,19 @@ describe('Exception builder helpers', () => { test('it returns formatted entries when nested entries exist', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); const payloadParent: EntryNested = { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }], + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], }; const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, { ...payloadParent }, ]; const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); const expected: FormattedBuilderEntry[] = [ { + id: '123', entryIndex: 0, field: { aggregatable: true, @@ -579,6 +628,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 1, field: { aggregatable: false, @@ -594,6 +644,7 @@ describe('Exception builder helpers', () => { correspondingKeywordField: undefined, }, { + id: '123', entryIndex: 0, field: { aggregatable: false, @@ -614,16 +665,18 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: { parent: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some host name', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, parentIndex: 1, }, @@ -637,15 +690,19 @@ describe('Exception builder helpers', () => { describe('#getUpdatedEntriesOnDelete', () => { test('it removes entry corresponding to "entryIndex"', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock() }; + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); const expected: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock(), entries: [ { + id: '123', field: 'some.not.nested.field', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some value', }, ], @@ -658,15 +715,17 @@ describe('Exception builder helpers', () => { ...getExceptionListItemSchemaMock(), entries: [ { - ...getEntryNestedMock(), - entries: [{ ...getEntryExistsMock() }, { ...getEntryMatchAnyMock() }], + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }], }, ], }; const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); const expected: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock(), - entries: [{ ...getEntryNestedMock(), entries: [{ ...getEntryMatchAnyMock() }] }], + entries: [ + { ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] }, + ], }; expect(output).toEqual(expected); }); @@ -676,8 +735,8 @@ describe('Exception builder helpers', () => { ...getExceptionListItemSchemaMock(), entries: [ { - ...getEntryNestedMock(), - entries: [{ ...getEntryExistsMock() }], + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }], }, ], }; @@ -698,10 +757,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', - type: 'match', + type: OperatorTypeEnum.MATCH, value: 'I should stay the same', }; expect(output).toEqual(expected); @@ -715,10 +775,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'I should stay the same', }; expect(output).toEqual(expected); @@ -732,10 +793,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatch & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: '', }; expect(output).toEqual(expected); @@ -749,10 +811,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['I should stay the same'], }; expect(output).toEqual(expected); @@ -766,10 +829,11 @@ describe('Exception builder helpers', () => { value: ['I should stay the same'], }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: ['I should stay the same'], }; expect(output).toEqual(expected); @@ -783,10 +847,11 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryMatchAny & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: [], }; expect(output).toEqual(expected); @@ -799,7 +864,8 @@ describe('Exception builder helpers', () => { operator: existsOperator, }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', operator: 'excluded', type: 'exists', @@ -814,9 +880,10 @@ describe('Exception builder helpers', () => { operator: doesNotExistOperator, }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'exists', }; expect(output).toEqual(expected); @@ -830,9 +897,10 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryExists & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'exists', }; expect(output).toEqual(expected); @@ -846,9 +914,10 @@ describe('Exception builder helpers', () => { value: 'I should stay the same', }; const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: Entry = { + const expected: EntryList & { id?: string } = { + id: '123', field: 'ip', - operator: 'included', + operator: OperatorEnum.INCLUDED, type: 'list', list: { id: '', type: 'ip' }, }; @@ -943,12 +1012,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); const payloadIFieldType: IFieldType = getField('nestedField.child'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { - entries: [{ field: 'child', operator: 'included', type: 'match', value: '' }], + id: '123', + entries: [ + { + id: '123', + field: 'child', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -959,24 +1037,34 @@ describe('Exception builder helpers', () => { ...getMockNestedBuilderEntry(), parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchMock(), field: 'child' }, getEntryMatchAnyMock()], + entries: [ + { ...getEntryMatchWithIdMock(), field: 'child' }, + getEntryMatchAnyWithIdMock(), + ], }, parentIndex: 0, }, }; const payloadIFieldType: IFieldType = getField('nestedField.child'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ - { field: 'child', operator: 'included', type: 'match', value: '' }, - getEntryMatchAnyMock(), + { + id: '123', + field: 'child', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + getEntryMatchAnyWithIdMock(), ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -986,12 +1074,13 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadIFieldType: IFieldType = getField('ip'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', field: 'ip', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: '', }, }; @@ -1004,8 +1093,14 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadOperator: OperatorOption = isNotOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match', value: 'some value', operator: 'excluded' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH, + value: 'some value', + operator: 'excluded', + }, index: 0, }; expect(output).toEqual(expected); @@ -1015,8 +1110,14 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); const payloadOperator: OperatorOption = isOneOfOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match_any', value: [], operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH_ANY, + value: [], + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1026,19 +1127,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); const payloadOperator: OperatorOption = isNotOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'excluded', - type: 'match', + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH, value: 'some value', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1048,19 +1151,21 @@ describe('Exception builder helpers', () => { const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); const payloadOperator: OperatorOption = isOneOfOperator; const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: [], }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1071,8 +1176,14 @@ describe('Exception builder helpers', () => { test('it returns entry with updated value', () => { const payload: FormattedBuilderEntry = getMockBuilderEntry(); const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: 'ip', type: 'match', value: 'jibber jabber', operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: 'ip', + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1081,8 +1192,14 @@ describe('Exception builder helpers', () => { test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { - updatedEntry: { field: '', type: 'match', value: 'jibber jabber', operator: 'included' }, + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + updatedEntry: { + id: '123', + field: '', + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + operator: OperatorEnum.INCLUDED, + }, index: 0, }; expect(output).toEqual(expected); @@ -1091,19 +1208,21 @@ describe('Exception builder helpers', () => { test('it returns nested entry with updated value', () => { const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'jibber jabber', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1112,19 +1231,21 @@ describe('Exception builder helpers', () => { test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: '', - operator: 'included', - type: 'match', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, value: 'jibber jabber', }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1139,12 +1260,13 @@ describe('Exception builder helpers', () => { value: ['some value'], }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: 'ip', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1159,12 +1281,13 @@ describe('Exception builder helpers', () => { field: undefined, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: '', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1176,27 +1299,29 @@ describe('Exception builder helpers', () => { ...getMockNestedBuilderEntry(), parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], }, parentIndex: 0, }, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: 'child', - operator: 'included', - type: 'match_any', + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], + operator: OperatorEnum.INCLUDED, }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1208,27 +1333,29 @@ describe('Exception builder helpers', () => { field: undefined, parent: { parent: { - ...getEntryNestedMock(), + ...getEntryNestedWithIdMock(), field: 'nestedField', - entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], }, parentIndex: 0, }, }; const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { index: 0, updatedEntry: { + id: '123', entries: [ { + id: '123', field: '', - operator: 'included', - type: 'match_any', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, value: ['jibber jabber'], }, ], field: 'nestedField', - type: 'nested', + type: OperatorTypeEnum.NESTED, }, }; expect(output).toEqual(expected); @@ -1243,12 +1370,13 @@ describe('Exception builder helpers', () => { value: '1234', }; const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: 'ip', type: 'list', list: { id: 'some-list-id', type: 'ip' }, - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; @@ -1263,12 +1391,13 @@ describe('Exception builder helpers', () => { field: undefined, }; const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry; index: number } = { + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { updatedEntry: { + id: '123', field: '', type: 'list', list: { id: 'some-list-id', type: 'ip' }, - operator: 'included', + operator: OperatorEnum.INCLUDED, }, index: 0, }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index a08f869b41d6f..8afdbce68c69a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -5,15 +5,15 @@ * 2.0. */ +import uuid from 'uuid'; + +import { addIdToItem } from '../../../../../common/add_remove_id_to_item'; import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; import { Entry, OperatorTypeEnum, EntryNested, ExceptionListType, - EntryMatch, - EntryMatchAny, - EntryExists, entriesList, ListSchema, OperatorEnum, @@ -160,6 +160,7 @@ export const getFormattedBuilderEntry = ( ? { ...foundField, name: foundField.name.split('.').slice(-1)[0] } : foundField, correspondingKeywordField, + id: item.id ?? `${itemIndex}`, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: 'child', @@ -169,6 +170,7 @@ export const getFormattedBuilderEntry = ( } else { return { field: foundField, + id: item.id ?? `${itemIndex}`, correspondingKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), @@ -215,6 +217,7 @@ export const getFormattedBuilderEntries = ( } else { const parentEntry: FormattedBuilderEntry = { operator: isOperator, + id: item.id ?? `${index}`, nested: 'parent', field: isNewNestedEntry ? undefined @@ -265,7 +268,7 @@ export const getUpdatedEntriesOnDelete = ( const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex]; if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { - const updatedEntryEntries: Array = [ + const updatedEntryEntries = [ ...itemOfInterest.entries.slice(0, entryIndex), ...itemOfInterest.entries.slice(entryIndex + 1), ]; @@ -282,6 +285,7 @@ export const getUpdatedEntriesOnDelete = ( const { field } = itemOfInterest; const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { field, + id: itemOfInterest.id ?? `${entryIndex}`, type: OperatorTypeEnum.NESTED, entries: updatedEntryEntries, }; @@ -317,12 +321,13 @@ export const getUpdatedEntriesOnDelete = ( export const getEntryFromOperator = ( selectedOperator: OperatorOption, currentEntry: FormattedBuilderEntry -): Entry => { +): Entry & { id?: string } => { const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; switch (selectedOperator.type) { case 'match': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.MATCH, operator: selectedOperator.operator, @@ -331,6 +336,7 @@ export const getEntryFromOperator = ( }; case 'match_any': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.MATCH_ANY, operator: selectedOperator.operator, @@ -338,6 +344,7 @@ export const getEntryFromOperator = ( }; case 'list': return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.LIST, operator: selectedOperator.operator, @@ -345,6 +352,7 @@ export const getEntryFromOperator = ( }; default: return { + id: currentEntry.id, field: fieldValue, type: OperatorTypeEnum.EXISTS, operator: selectedOperator.operator, @@ -397,7 +405,7 @@ export const getEntryOnFieldChange = ( if (nested === 'parent') { // For nested entries, when user first selects to add a nested - // entry, they first see a row similiar to what is shown for when + // entry, they first see a row similar to what is shown for when // a user selects "exists", as soon as they make a selection // we can now identify the 'parent' and 'child' this is where // we first convert the entry into type "nested" @@ -408,15 +416,16 @@ export const getEntryOnFieldChange = ( return { updatedEntry: { + id: item.id, field: newParentFieldValue, type: OperatorTypeEnum.NESTED, entries: [ - { + addIdToItem({ field: newChildFieldValue ?? '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, value: '', - }, + }), ], }, index: entryIndex, @@ -428,6 +437,7 @@ export const getEntryOnFieldChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: newChildFieldValue ?? '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, @@ -441,6 +451,7 @@ export const getEntryOnFieldChange = ( } else { return { updatedEntry: { + id: item.id, field: newField != null ? newField.name : '', type: OperatorTypeEnum.MATCH, operator: isOperator.operator, @@ -508,6 +519,7 @@ export const getEntryOnMatchChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: fieldName, type: OperatorTypeEnum.MATCH, operator: operator.operator, @@ -521,6 +533,7 @@ export const getEntryOnMatchChange = ( } else { return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.MATCH, operator: operator.operator, @@ -554,6 +567,7 @@ export const getEntryOnMatchAnyChange = ( entries: [ ...parent.parent.entries.slice(0, entryIndex), { + id: item.id, field: fieldName, type: OperatorTypeEnum.MATCH_ANY, operator: operator.operator, @@ -567,6 +581,7 @@ export const getEntryOnMatchAnyChange = ( } else { return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.MATCH_ANY, operator: operator.operator, @@ -594,6 +609,7 @@ export const getEntryOnListChange = ( return { updatedEntry: { + id: item.id, field: field != null ? field.name : '', type: OperatorTypeEnum.LIST, operator: operator.operator, @@ -604,6 +620,7 @@ export const getEntryOnListChange = ( }; export const getDefaultEmptyEntry = (): EmptyEntry => ({ + id: uuid.v4(), field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -611,6 +628,7 @@ export const getDefaultEmptyEntry = (): EmptyEntry => ({ }); export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ + id: uuid.v4(), field: '', type: OperatorTypeEnum.NESTED, entries: [], diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index 46e3ec08e7c50..3789d8e75fa2e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useReducer } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; +import { addIdToItem } from '../../../../../common'; import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; import { BuilderExceptionListItemComponent } from './exception_item'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; @@ -240,8 +241,6 @@ export const ExceptionBuilderComponent = ({ entries: [...entries, isNested ? getDefaultNestedEmptyEntry() : getDefaultEmptyEntry()], }; - // setAndLogicIncluded(updatedException.entries.length > 1); - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); }, [setUpdateExceptions, exceptions] @@ -287,12 +286,12 @@ export const ExceptionBuilderComponent = ({ ...lastEntry, entries: [ ...lastEntry.entries, - { + addIdToItem({ field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, value: '', - }, + }), ], }, ], @@ -352,7 +351,7 @@ export const ExceptionBuilderComponent = ({ }, []); return ( - + {exceptions.map((exceptionListItem, index) => ( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts index 0741f561c1933..dbac7d325b63a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.test.ts @@ -14,6 +14,10 @@ import { ExceptionsBuilderExceptionItem } from '../types'; import { Action, State, exceptionsBuilderReducer } from './reducer'; import { getDefaultEmptyEntry } from './helpers'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const initialState: State = { disableAnd: false, disableNested: false, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 336732016e936..954a75fc370bd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -382,7 +382,9 @@ export const EditExceptionModal = memo(function EditExceptionModal({ )} {updateError == null && ( - {i18n.CANCEL} + + {i18n.CANCEL} + ({ + v4: jest.fn().mockReturnValue('123'), +})); + describe('Exception helpers', () => { beforeEach(() => { moment.tz.setDefault('UTC'); @@ -229,9 +237,22 @@ describe('Exception helpers', () => { }); describe('#filterExceptionItems', () => { + // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes + // for context around the temporary `id` + test('it correctly validates entries that include a temporary `id`', () => { + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }, + ]); + + expect(output).toEqual([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); + }); + test('it removes entry items with "value" of "undefined"', () => { const { entries, ...rest } = getExceptionListItemSchemaMock(); const mockEmptyException: EmptyEntry = { + id: '123', field: 'host.name', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -250,6 +271,7 @@ describe('Exception helpers', () => { test('it removes "match" entry items with "value" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: 'host.name', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -270,6 +292,7 @@ describe('Exception helpers', () => { test('it removes "match" entry items with "field" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, @@ -290,6 +313,7 @@ describe('Exception helpers', () => { test('it removes "match_any" entry items with "field" of empty string', () => { const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; const mockEmptyException: EmptyEntry = { + id: '123', field: '', type: OperatorTypeEnum.MATCH_ANY, operator: OperatorEnum.INCLUDED, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 13ee06e8cbac9..c44de4f05e7f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -44,6 +44,7 @@ import { validate } from '../../../../common/validate'; import { Ecs } from '../../../../common/ecs'; import { CodeSignature } from '../../../../common/ecs/file'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; +import { addIdToItem, removeIdFromItem } from '../../../../common'; /** * Returns the operator type, may not need this if using io-ts types @@ -150,12 +151,12 @@ export const getNewExceptionItem = ({ comments: [], description: `${ruleName} - exception list item`, entries: [ - { + addIdToItem({ field: '', operator: 'included', type: 'match', value: '', - }, + }), ], item_id: undefined, list_id: listId, @@ -175,26 +176,32 @@ export const filterExceptionItems = ( return exceptions.reduce>( (acc, exception) => { const entries = exception.entries.reduce((nestedAcc, singleEntry) => { - if (singleEntry.type === 'nested') { - const nestedEntriesArray = singleEntry.entries.filter((singleNestedEntry) => { - const [validatedNestedEntry] = validate(singleNestedEntry, nestedEntryItem); + const strippedSingleEntry = removeIdFromItem(singleEntry); + + if (entriesNested.is(strippedSingleEntry)) { + const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { + const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); + const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); return validatedNestedEntry != null; }); + const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => + removeIdFromItem(singleNestedEntry) + ); const [validatedNestedEntry] = validate( - { ...singleEntry, entries: nestedEntriesArray }, + { ...strippedSingleEntry, entries: noIdNestedEntries }, entriesNested ); if (validatedNestedEntry != null) { - return [...nestedAcc, validatedNestedEntry]; + return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; } return nestedAcc; } else { - const [validatedEntry] = validate(singleEntry, entry); + const [validatedEntry] = validate(strippedSingleEntry, entry); if (validatedEntry != null) { - return [...nestedAcc, validatedEntry]; + return [...nestedAcc, singleEntry]; } return nestedAcc; } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 6108a21ce5624..c7a125daa54f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -60,16 +60,18 @@ export interface ExceptionsPagination { } export interface FormattedBuilderEntry { + id: string; field: IFieldType | undefined; operator: OperatorOption; value: string | string[] | undefined; nested: 'parent' | 'child' | undefined; entryIndex: number; - parent: { parent: EntryNested; parentIndex: number } | undefined; + parent: { parent: BuilderEntryNested; parentIndex: number } | undefined; correspondingKeywordField: IFieldType | undefined; } export interface EmptyEntry { + id: string; field: string | undefined; operator: OperatorEnum; type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY; @@ -77,6 +79,7 @@ export interface EmptyEntry { } export interface EmptyListEntry { + id: string; field: string | undefined; operator: OperatorEnum; type: OperatorTypeEnum.LIST; @@ -84,12 +87,31 @@ export interface EmptyListEntry { } export interface EmptyNestedEntry { + id: string; field: string | undefined; type: OperatorTypeEnum.NESTED; - entries: Array; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; } -export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested | EmptyNestedEntry; +export type BuilderEntry = + | (Entry & { id?: string }) + | EmptyListEntry + | EmptyEntry + | BuilderEntryNested + | EmptyNestedEntry; + +export type BuilderEntryNested = Omit & { + id?: string; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; +}; export type ExceptionListItemBuilderSchema = Omit & { entries: BuilderEntry[]; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 5cea37347b565..571cef8f6c484 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -146,10 +146,7 @@ export const getBreadcrumbsForRoute = ( } if (isTimelinesRoutes(spyState) && object.navTabs) { const tempNav: SearchNavTab = { urlKey: 'timeline', isDetailPage: false }; - let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; - if (spyState.tabName != null) { - urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; - } + const urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; return [ siemRootBreadcrumb, diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx index eced8d785792d..ef3e9280e6e6b 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx @@ -14,7 +14,7 @@ import { import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common'; import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types'; -import { addIdToItem } from '../../utils/add_remove_id_to_item'; +import { addIdToItem } from '../../../../common/add_remove_id_to_item'; /** * Formats the entry into one that is easily usable for the UI. diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx index 7e2da88a58f18..af3e427056867 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx @@ -157,49 +157,54 @@ export const buildThreatDescription = ({ label, threat }: BuildThreatDescription : `${singleThreat.tactic.name} (${singleThreat.tactic.id})`} - {singleThreat.technique.map((technique, techniqueIndex) => { - const myTechnique = techniquesOptions.find((t) => t.id === technique.id); - return ( - - - {myTechnique != null - ? myTechnique.label - : `${technique.name} (${technique.id})`} - - - {technique.subtechnique != null && - technique.subtechnique.map((subtechnique, subtechniqueIndex) => { - const mySubtechnique = subtechniquesOptions.find( - (t) => t.id === subtechnique.id - ); - return ( - - { + const myTechnique = techniquesOptions.find((t) => t.id === technique.id); + return ( + + + {myTechnique != null + ? myTechnique.label + : `${technique.name} (${technique.id})`} + + + {technique.subtechnique != null && + technique.subtechnique.map((subtechnique, subtechniqueIndex) => { + const mySubtechnique = subtechniquesOptions.find( + (t) => t.id === subtechnique.id + ); + return ( + - {mySubtechnique != null - ? mySubtechnique.label - : `${subtechnique.name} (${subtechnique.id})`} - - - ); - })} - - - ); - })} + + {mySubtechnique != null + ? mySubtechnique.label + : `${subtechnique.name} (${subtechnique.id})`} + + + ); + })} + + + ); + })} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/helpers.test.tsx index da18f28257452..2a083ef89ab19 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/helpers.test.tsx @@ -8,7 +8,7 @@ import { getValidThreat } from '../../../mitre/valid_threat_mock'; import { hasSubtechniqueOptions } from './helpers'; -const mockTechniques = getValidThreat()[0].technique; +const mockTechniques = getValidThreat()[0].technique ?? []; describe('helpers', () => { describe('hasSubtechniqueOptions', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/subtechnique_fields.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/subtechnique_fields.tsx index e3c771534beda..d283c19bd13da 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/mitre/subtechnique_fields.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/mitre/subtechnique_fields.tsx @@ -51,45 +51,46 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ const values = field.value as Threats; const technique = useMemo(() => { - return values[threatIndex].technique[techniqueIndex]; - }, [values, threatIndex, techniqueIndex]); + return [...(values[threatIndex].technique ?? [])]; + }, [values, threatIndex]); const removeSubtechnique = useCallback( (index: number) => { const threats = [...(field.value as Threats)]; - const subtechniques = threats[threatIndex].technique[techniqueIndex].subtechnique; + const subtechniques = technique[techniqueIndex].subtechnique ?? []; if (subtechniques != null) { subtechniques.splice(index, 1); - threats[threatIndex].technique[techniqueIndex] = { - ...threats[threatIndex].technique[techniqueIndex], + technique[techniqueIndex] = { + ...technique[techniqueIndex], subtechnique: subtechniques, }; + threats[threatIndex].technique = technique; onFieldChange(threats); } }, - [field, threatIndex, onFieldChange, techniqueIndex] + [field, onFieldChange, techniqueIndex, technique, threatIndex] ); const addMitreAttackSubtechnique = useCallback(() => { const threats = [...(field.value as Threats)]; - const subtechniques = threats[threatIndex].technique[techniqueIndex].subtechnique; + const subtechniques = technique[techniqueIndex].subtechnique; if (subtechniques != null) { - threats[threatIndex].technique[techniqueIndex] = { - ...threats[threatIndex].technique[techniqueIndex], + technique[techniqueIndex] = { + ...technique[techniqueIndex], subtechnique: [...subtechniques, { id: 'none', name: 'none', reference: 'none' }], }; } else { - threats[threatIndex].technique[techniqueIndex] = { - ...threats[threatIndex].technique[techniqueIndex], + technique[techniqueIndex] = { + ...technique[techniqueIndex], subtechnique: [{ id: 'none', name: 'none', reference: 'none' }], }; } - + threats[threatIndex].technique = technique; onFieldChange(threats); - }, [field, threatIndex, onFieldChange, techniqueIndex]); + }, [field, onFieldChange, techniqueIndex, technique, threatIndex]); const updateSubtechnique = useCallback( (index: number, value: string) => { @@ -99,7 +100,7 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ name: '', reference: '', }; - const subtechniques = threats[threatIndex].technique[techniqueIndex].subtechnique; + const subtechniques = technique[techniqueIndex].subtechnique; if (subtechniques != null) { onFieldChange([ @@ -107,9 +108,9 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ { ...threats[threatIndex], technique: [ - ...threats[threatIndex].technique.slice(0, techniqueIndex), + ...technique.slice(0, techniqueIndex), { - ...threats[threatIndex].technique[techniqueIndex], + ...technique[techniqueIndex], subtechnique: [ ...subtechniques.slice(0, index), { @@ -120,19 +121,21 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ ...subtechniques.slice(index + 1), ], }, - ...threats[threatIndex].technique.slice(techniqueIndex + 1), + ...technique.slice(techniqueIndex + 1), ], }, ...threats.slice(threatIndex + 1), ]); } }, - [threatIndex, techniqueIndex, onFieldChange, field] + [threatIndex, techniqueIndex, onFieldChange, field, technique] ); const getSelectSubtechnique = useCallback( (index: number, disabled: boolean, subtechnique: ThreatSubtechnique) => { - const options = subtechniquesOptions.filter((t) => t.techniqueId === technique.id); + const options = subtechniquesOptions.filter( + (t) => t.techniqueId === technique[techniqueIndex].id + ); return ( <> @@ -166,13 +169,17 @@ export const MitreAttackSubtechniqueFields: React.FC = ({ ); }, - [field, updateSubtechnique, technique] + [field, updateSubtechnique, technique, techniqueIndex] ); + const subtechniques = useMemo(() => { + return technique[techniqueIndex].subtechnique; + }, [technique, techniqueIndex]); + return ( - {technique.subtechnique != null && - technique.subtechnique.map((subtechnique, index) => ( + {subtechniques != null && + subtechniques.map((subtechnique, index) => (
= ({ const removeTechnique = useCallback( (index: number) => { const threats = [...(field.value as Threats)]; - const techniques = threats[threatIndex].technique; + const techniques = threats[threatIndex].technique ?? []; techniques.splice(index, 1); threats[threatIndex] = { ...threats[threatIndex], @@ -73,7 +73,7 @@ export const MitreAttackTechniqueFields: React.FC = ({ threats[threatIndex] = { ...threats[threatIndex], technique: [ - ...threats[threatIndex].technique, + ...(threats[threatIndex].technique ?? []), { id: 'none', name: 'none', reference: 'none', subtechnique: [] }, ], }; @@ -88,19 +88,20 @@ export const MitreAttackTechniqueFields: React.FC = ({ name: '', reference: '', }; + const technique = threats[threatIndex].technique ?? []; onFieldChange([ ...threats.slice(0, threatIndex), { ...threats[threatIndex], technique: [ - ...threats[threatIndex].technique.slice(0, index), + ...technique.slice(0, index), { id, reference, name, subtechnique: [], }, - ...threats[threatIndex].technique.slice(index + 1), + ...technique.slice(index + 1), ], }, ...threats.slice(threatIndex + 1), @@ -147,9 +148,11 @@ export const MitreAttackTechniqueFields: React.FC = ({ [field, updateTechnique] ); + const techniques = values[threatIndex].technique ?? []; + return ( - {values[threatIndex].technique.map((technique, index) => ( + {techniques.map((technique, index) => (
= ({ euiFieldProps: { fullWidth: true, disabled: isLoading, - placeholder: DEFAULT_INDICATOR_PATH, + placeholder: DEFAULT_INDICATOR_SOURCE_PATH, }, }} /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx index 5e4d08c4e7939..07012af17e734 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx @@ -205,7 +205,7 @@ export const schema: FormSchema = { 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThreatIndicatorPathHelpText', { defaultMessage: - 'Specify the document path containing your threat indicator fields. Used for enrichment of indicator match alerts. Defaults to threat.indicator unless otherwise specified.', + 'Specify the document path containing your threat indicator fields. Used for enrichment of indicator match alerts.', } ), labelAppend: OptionalFieldLabel, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts index 45961454b9c78..7eb91e259a72f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts @@ -6,7 +6,7 @@ */ import { flow } from 'fp-ts/lib/function'; -import { addIdToItem, removeIdFromItem } from '../../../../common/utils/add_remove_id_to_item'; +import { addIdToItem, removeIdFromItem } from '../../../../../common/add_remove_id_to_item'; import { CreateRulesSchema, UpdateRulesSchema, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx index 3b1f9e620127d..6cc75a3fda03c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx @@ -8,6 +8,7 @@ import * as H from 'history'; import React, { Dispatch } from 'react'; +import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; import { deleteRules, duplicateRules, @@ -28,6 +29,7 @@ import { track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../../../common/lib/t import * as i18n from '../translations'; import { bucketRulesResponse } from './helpers'; +import { transformOutput } from '../../../../containers/detection_engine/rules/transforms'; export const editRuleAction = (rule: Rule, history: H.History) => { history.push(getEditRuleUrl(rule.id)); @@ -41,7 +43,11 @@ export const duplicateRulesAction = async ( ) => { try { dispatch({ type: 'loadingRuleIds', ids: ruleIds, actionType: 'duplicate' }); - const response = await duplicateRules({ rules }); + const response = await duplicateRules({ + // We cast this back and forth here as the front end types are not really the right io-ts ones + // and the two types conflict with each other. + rules: rules.map((rule) => transformOutput(rule as CreateRulesSchema) as Rule), + }); const { errors } = bucketRulesResponse(response); if (errors.length > 0) { displayErrorToast( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index d2488bd3d043c..d2eadef48d9c7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -67,6 +67,7 @@ export const getActions = ( enabled: (rowItem: Rule) => canEditRuleWithActions(rowItem, actionsPrivileges), }, { + 'data-test-subj': 'duplicateRuleAction', description: i18n.DUPLICATE_RULE, icon: 'copy', name: !actionsPrivileges ? ( 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 12e6d276c18d8..b8824d2b8798e 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 @@ -182,7 +182,7 @@ export const filterEmptyThreats = (threats: Threats): Threats => { .map((threat) => { return { ...threat, - technique: trimThreatsWithNoName(threat.technique).map((technique) => { + technique: trimThreatsWithNoName(threat.technique ?? []).map((technique) => { return { ...technique, subtechnique: diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx index ae8a00d2b4aa0..004e675cb3516 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx @@ -113,7 +113,7 @@ const StatefulRecentTimelinesComponent: React.FC = ({ apolloClient, filte )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index 49f68281ae103..c130ea4c96814 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -7,12 +7,26 @@ import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModal } from './delete_timeline_modal'; import * as i18n from '../translations'; +import { TimelineType } from '../../../../../common/types/timeline'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); describe('DeleteTimelineModal', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); + }); + test('it renders the expected title when a timeline is selected', () => { const wrapper = mountWithIntl( { /> ); - expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual(i18n.DELETE_WARNING); + expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual( + i18n.DELETE_TIMELINE_WARNING + ); }); test('it invokes closeModal when the Cancel button is clicked', () => { @@ -115,3 +131,23 @@ describe('DeleteTimelineModal', () => { expect(onDelete).toBeCalled(); }); }); + +describe('DeleteTimelineTemplateModal', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.template }); + }); + + test('it renders a deletion warning', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual( + i18n.DELETE_TIMELINE_TEMPLATE_WARNING + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 5538610487899..f0efda6528507 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -10,7 +10,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; +import { useParams } from 'react-router-dom'; import * as i18n from '../translations'; +import { TimelineType } from '../../../../../common/types/timeline'; interface Props { title?: string | null; @@ -24,6 +26,12 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px * Renders a modal that confirms deletion of a timeline */ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDelete }) => { + const { tabName } = useParams<{ tabName: TimelineType }>(); + const warning = + tabName === TimelineType.template + ? i18n.DELETE_TIMELINE_TEMPLATE_WARNING + : i18n.DELETE_TIMELINE_WARNING; + const getTitle = useCallback(() => { const trimmedTitle = title != null ? title.trim() : ''; const titleResult = !isEmpty(trimmedTitle) ? trimmedTitle : i18n.UNTITLED_TIMELINE; @@ -48,7 +56,7 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel onConfirm={onDelete} title={getTitle()} > -
{i18n.DELETE_WARNING}
+
{warning}
); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx index 18a6ffc06941c..cfbc7d255062f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -7,8 +7,18 @@ import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModalOverlay } from '.'; +import { TimelineType } from '../../../../../common/types/timeline'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); describe('DeleteTimelineModal', () => { const savedObjectId = 'abcd'; @@ -20,6 +30,10 @@ describe('DeleteTimelineModal', () => { title: 'Privilege Escalation', }; + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); + }); + describe('showModalState', () => { test('it does NOT render the modal when isModalOpen is false', () => { const testProps = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 24b0702770d3c..5a3da748bea1d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -122,9 +122,9 @@ export const OpenTimeline = React.memo( const onRefreshBtnClick = useCallback(() => { if (refetch != null) { - refetch(searchResults, totalSearchResultsCount); + refetch(); } - }, [refetch, searchResults, totalSearchResultsCount]); + }, [refetch]); const handleCloseModal = useCallback(() => { if (setImportDataModalToggle != null) { @@ -137,9 +137,9 @@ export const OpenTimeline = React.memo( setImportDataModalToggle(false); } if (refetch != null) { - refetch(searchResults, totalSearchResultsCount); + refetch(); } - }, [setImportDataModalToggle, refetch, searchResults, totalSearchResultsCount]); + }, [setImportDataModalToggle, refetch]); const actionTimelineToShow = useMemo(() => { const timelineActions: ActionTimelineToShow[] = ['createFrom', 'duplicate']; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index 8c553bb95e9bd..c1b30f3e68cf4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -25,7 +25,11 @@ import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; import { getExtendedColumns } from './extended_columns'; import { getIconHeaderColumns } from './icon_header_columns'; -import { TimelineTypeLiteralWithNull, TimelineStatus } from '../../../../../common/types/timeline'; +import { + TimelineTypeLiteralWithNull, + TimelineStatus, + TimelineType, +} from '../../../../../common/types/timeline'; // there are a number of type mismatches across this file const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -103,7 +107,7 @@ export interface TimelinesTableProps { onToggleShowNotes: OnToggleShowNotes; pageIndex: number; pageSize: number; - searchResults: OpenTimelineResult[]; + searchResults: OpenTimelineResult[] | null; showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; @@ -196,6 +200,13 @@ export const TimelinesTable = React.memo( ] ); + const noItemsMessage = + isLoading || searchResults == null + ? i18n.LOADING + : timelineType === TimelineType.template + ? i18n.ZERO_TIMELINE_TEMPLATES_MATCH + : i18n.ZERO_TIMELINES_MATCH; + return ( ( isSelectable={actionTimelineToShow.includes('selectable')} itemId="savedObjectId" itemIdToExpandedRowMap={itemIdToExpandedNotesRowMap} - items={searchResults} + items={searchResults ?? []} loading={isLoading} - noItemsMessage={i18n.ZERO_TIMELINES_MATCH} + noItemsMessage={noItemsMessage} onChange={onTableChange} pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index ae743ad30eef1..4858bf3ed6083 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -33,13 +33,21 @@ export const DELETE_SELECTED = i18n.translate( } ); -export const DELETE_WARNING = i18n.translate( +export const DELETE_TIMELINE_WARNING = i18n.translate( 'xpack.securitySolution.open.timeline.deleteWarningLabel', { defaultMessage: 'You will not be able to recover this timeline or its notes once deleted.', } ); +export const DELETE_TIMELINE_TEMPLATE_WARNING = i18n.translate( + 'xpack.securitySolution.open.timeline.deleteTemplateWarningLabel', + { + defaultMessage: + 'You will not be able to recover this timeline template or its notes once deleted.', + } +); + export const DESCRIPTION = i18n.translate( 'xpack.securitySolution.open.timeline.descriptionTableHeader', { @@ -204,6 +212,10 @@ export const WITH = i18n.translate('xpack.securitySolution.open.timeline.withLab defaultMessage: 'with', }); +export const LOADING = i18n.translate('xpack.securitySolution.open.timeline.loadingLabel', { + defaultMessage: 'Loading...', +}); + export const ZERO_TIMELINES_MATCH = i18n.translate( 'xpack.securitySolution.open.timeline.zeroTimelinesMatchLabel', { @@ -211,6 +223,13 @@ export const ZERO_TIMELINES_MATCH = i18n.translate( } ); +export const ZERO_TIMELINE_TEMPLATES_MATCH = i18n.translate( + 'xpack.securitySolution.open.timeline.zeroTimelineTemplatesMatchLabel', + { + defaultMessage: '0 timeline templates match the search criteria', + } +); + export const SINGLE_TIMELINE = i18n.translate( 'xpack.securitySolution.open.timeline.singleTimelineLabel', { @@ -305,14 +324,14 @@ export const FILTER_CUSTOM_TIMELINES = i18n.translate( export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle', { - defaultMessage: 'Import timeline', + defaultMessage: 'Import', } ); export const SELECT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription', { - defaultMessage: 'Select a Security timeline (as exported from the Timeline view) to import', + defaultMessage: 'Select a timeline or timeline template file to import', } ); @@ -343,14 +362,14 @@ export const SUCCESSFULLY_IMPORTED_TIMELINES = (totalCount: number) => export const IMPORT_FAILED = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importFailedTitle', { - defaultMessage: 'Failed to import timelines', + defaultMessage: 'Failed to import', } ); export const IMPORT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTitle', { - defaultMessage: 'Import timeline…', + defaultMessage: 'Import…', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index f5ba9959ae898..680bb6f975983 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -167,9 +167,9 @@ export interface OpenTimelineProps { /** The currently applied search criteria */ query: string; /** Refetch table */ - refetch?: (existingTimeline?: OpenTimelineResult[], existingCount?: number) => void; - /** The results of executing a search */ - searchResults: OpenTimelineResult[]; + refetch?: () => void; + /** The results of executing a search, null is the status before data fatched */ + searchResults: OpenTimelineResult[] | null; /** the currently-selected timelines in the table */ selectedItems: OpenTimelineResult[]; /** Toggle export timelines modal*/ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx index d4de77e04e9f7..7ccce80bbe9a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx @@ -222,7 +222,7 @@ const SelectableTimelineComponent: React.FC = ({ windowProps: { onScroll: ({ scrollOffset }) => handleOnScroll( - timelines.filter((t) => !hideUntitled || t.title !== '').length, + (timelines ?? []).filter((t) => !hideUntitled || t.title !== '').length, timelineCount, scrollOffset ), @@ -254,7 +254,7 @@ const SelectableTimelineComponent: React.FC = ({ = ({ searchProps={searchProps} singleSelection={true} options={getSelectableOptions({ - timelines, + timelines: timelines ?? [], onlyFavorites, searchTimelineValue, timelineType, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index b14ccbd319399..82b41a95bd537 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -38,7 +38,7 @@ export interface AllTimelinesArgs { status, timelineType, }: AllTimelinesVariables) => void; - timelines: OpenTimelineResult[]; + timelines: OpenTimelineResult[] | null; loading: boolean; totalCount: number; customTemplateTimelineCount: number; @@ -105,7 +105,7 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { const [allTimelines, setAllTimelines] = useState>({ loading: false, totalCount: 0, - timelines: [], + timelines: null, // use null as initial state to distinguish between empty result and haven't started loading. customTemplateTimelineCount: 0, defaultTimelineCount: 0, elasticTemplateTimelineCount: 0, @@ -128,7 +128,10 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { const fetchData = async () => { try { if (apolloClient != null) { - setAllTimelines((prevState) => ({ ...prevState, loading: true })); + setAllTimelines((prevState) => ({ + ...prevState, + loading: true, + })); const variables: GetAllTimeline.Variables = { onlyUserFavorite, diff --git a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx index 53ea28832f47f..806ac57df1f65 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx @@ -12,7 +12,6 @@ import { Switch, Route, useHistory } from 'react-router-dom'; import { ChromeBreadcrumb } from '../../../../../../src/core/public'; import { TimelineType } from '../../../common/types/timeline'; -import { TAB_TIMELINES, TAB_TEMPLATES } from '../components/open_timeline/translations'; import { TimelineRouteSpyState } from '../../common/utils/route/types'; import { TimelinesPage } from './timelines_page'; @@ -25,37 +24,18 @@ import { SecurityPageName } from '../../app/types'; const timelinesPagePath = `/:tabName(${TimelineType.default}|${TimelineType.template})`; const timelinesDefaultPath = `/${TimelineType.default}`; -const TabNameMappedToI18nKey: Record = { - [TimelineType.default]: TAB_TIMELINES, - [TimelineType.template]: TAB_TEMPLATES, -}; - export const getBreadcrumbs = ( params: TimelineRouteSpyState, search: string[], getUrlForApp: GetUrlForApp -): ChromeBreadcrumb[] => { - let breadcrumb = [ - { - text: PAGE_TITLE, - href: getUrlForApp(`${APP_ID}:${SecurityPageName.timelines}`, { - path: !isEmpty(search[0]) ? search[0] : '', - }), - }, - ]; - - const tabName = params?.tabName; - if (!tabName) return breadcrumb; - - breadcrumb = [ - ...breadcrumb, - { - text: TabNameMappedToI18nKey[tabName], - href: '', - }, - ]; - return breadcrumb; -}; +): ChromeBreadcrumb[] => [ + { + text: PAGE_TITLE, + href: getUrlForApp(`${APP_ID}:${SecurityPageName.timelines}`, { + path: !isEmpty(search[0]) ? search[0] : '', + }), + }, +]; export const Timelines = React.memo(() => { const history = useHistory(); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts index f3bff98785619..199fc27c2663a 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts @@ -21,7 +21,7 @@ export const ALL_TIMELINES_PANEL_TITLE = i18n.translate( export const ALL_TIMELINES_IMPORT_TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.timelines.allTimelines.importTimelineTitle', { - defaultMessage: 'Import Timeline', + defaultMessage: 'Import', } ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 55d128225c555..50823ebd85d05 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import moment from 'moment'; import { sampleRuleAlertParams, sampleEmptyDocSearchResults, @@ -23,9 +22,10 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mock import uuid from 'uuid'; import { listMock } from '../../../../../lists/server/mocks'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { BulkResponse } from './types'; +import { BulkResponse, RuleRangeTuple } from './types'; import { SearchListItemArraySchema } from '../../../../../lists/common/schemas'; import { getSearchListItemResponseMock } from '../../../../../lists/common/schemas/response/search_list_item_schema.mock'; +import { getRuleRangeTuples } from './utils'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -39,16 +39,26 @@ describe('searchAfterAndBulkCreate', () => { let inputIndexPattern: string[] = []; let listClient = listMock.getListClient(); const someGuids = Array.from({ length: 13 }).map(() => uuid.v4()); + const sampleParams = sampleRuleAlertParams(30); + let tuples: RuleRangeTuple[]; beforeEach(() => { jest.clearAllMocks(); listClient = listMock.getListClient(); listClient.searchListItemByValues = jest.fn().mockResolvedValue([]); inputIndexPattern = ['auditbeat-*']; mockService = alertsMock.createAlertServices(); + ({ tuples } = getRuleRangeTuples({ + logger: mockLogger, + previousStartedAt: new Date(), + from: sampleParams.from, + to: sampleParams.to, + interval: '5m', + maxSignals: sampleParams.maxSignals, + buildRuleMessage, + })); }); test('should return success with number of searches less than max signals', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -112,11 +122,9 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -147,7 +155,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success with number of searches less than max signals with gap', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -201,8 +208,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: moment.duration(2, 'm'), - previousStartedAt: moment().subtract(10, 'm').toDate(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -233,7 +239,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success when no search results are in the allowlist', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -278,8 +283,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -305,7 +309,7 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(3); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -316,7 +320,6 @@ describe('searchAfterAndBulkCreate', () => { { ...getSearchListItemResponseMock(), value: ['3.3.3.3'] }, ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce( repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ @@ -342,8 +345,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -382,7 +384,6 @@ describe('searchAfterAndBulkCreate', () => { ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce( repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3), [ '1.1.1.1', @@ -406,8 +407,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -437,13 +437,12 @@ describe('searchAfterAndBulkCreate', () => { expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best // way I can think of to ensure this section is getting hit with this test case. - expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[8][0]).toContain( + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[7][0]).toContain( 'ran out of sort ids to sort on name: "fake name" id: "fake id" rule id: "fake rule id" signals index: "fakeindex"' ); }); test('should return success when no sortId present but search results are in the allowlist', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -487,8 +486,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -514,17 +512,16 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(2); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best // way I can think of to ensure this section is getting hit with this test case. - expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[15][0]).toContain( + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[14][0]).toContain( 'ran out of sort ids to sort on name: "fake name" id: "fake id" rule id: "fake rule id" signals index: "fakeindex"' ); }); test('should return success when no exceptions list provided', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -565,8 +562,7 @@ describe('searchAfterAndBulkCreate', () => { ); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [], services: mockService, @@ -592,7 +588,7 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(3); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -609,15 +605,13 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const sampleParams = sampleRuleAlertParams(10); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockRejectedValue(new Error('bulk failed')); // Added this recently const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -659,7 +653,6 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce(sampleEmptyDocSearchResults()); listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( @@ -672,8 +665,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -702,7 +694,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('if returns false when singleSearchAfter throws an exception', async () => { - const sampleParams = sampleRuleAlertParams(10); mockService.callCluster .mockResolvedValueOnce({ took: 100, @@ -741,8 +732,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -771,7 +761,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('it returns error array when singleSearchAfter returns errors', async () => { - const sampleParams = sampleRuleAlertParams(30); const bulkItem: BulkResponse = { took: 100, errors: true, @@ -832,7 +821,6 @@ describe('searchAfterAndBulkCreate', () => { ], }) .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); - const { success, createdSignalsCount, @@ -840,8 +828,7 @@ describe('searchAfterAndBulkCreate', () => { errors, } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [], services: mockService, @@ -873,7 +860,6 @@ describe('searchAfterAndBulkCreate', () => { }); it('invokes the enrichment callback with signal search results', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -917,8 +903,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ enrichment: mockEnrichment, ruleParams: sampleParams, - gap: moment.duration(2, 'm'), - previousStartedAt: moment().subtract(10, 'm').toDate(), + tuples, listClient, exceptionsList: [], services: mockService, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 061aa4bba5a41..1dd3a2d2173a8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -17,7 +17,6 @@ import { createSearchResultReturnType, createSearchAfterReturnTypeFromResponse, createTotalHitsFromSearchResult, - getSignalTimeTuples, mergeReturns, mergeSearchResults, } from './utils'; @@ -25,8 +24,7 @@ import { SearchAfterAndBulkCreateParams, SearchAfterAndBulkCreateReturnType } fr // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ - gap, - previousStartedAt, + tuples: totalToFromTuples, ruleParams, exceptionsList, services, @@ -64,16 +62,6 @@ export const searchAfterAndBulkCreate = async ({ // to ensure we don't exceed maxSignals let signalsCreatedCount = 0; - const totalToFromTuples = getSignalTimeTuples({ - logger, - ruleParamsFrom: ruleParams.from, - ruleParamsTo: ruleParams.to, - ruleParamsMaxSignals: ruleParams.maxSignals, - gap, - previousStartedAt, - interval, - buildRuleMessage, - }); const tuplesToBeLogged = [...totalToFromTuples]; logger.debug(buildRuleMessage(`totalToFromTuples: ${totalToFromTuples.length}`)); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index a79961eb716fd..cadc6d0c5b7c0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -11,14 +11,7 @@ import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; -import { - getGapBetweenRuns, - getGapMaxCatchupRatio, - getListsClient, - getExceptions, - sortExceptionItems, - checkPrivileges, -} from './utils'; +import { getListsClient, getExceptions, sortExceptionItems, checkPrivileges } from './utils'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; import { RuleExecutorOptions, SearchAfterAndBulkCreateReturnType } from './types'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; @@ -40,8 +33,6 @@ jest.mock('./utils', () => { const original = jest.requireActual('./utils'); return { ...original, - getGapBetweenRuns: jest.fn(), - getGapMaxCatchupRatio: jest.fn(), getListsClient: jest.fn(), getExceptions: jest.fn(), sortExceptionItems: jest.fn(), @@ -113,7 +104,6 @@ describe('rules_notification_alert_type', () => { warning: jest.fn(), }; (ruleStatusServiceFactory as jest.Mock).mockReturnValue(ruleStatusService); - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(0)); (getListsClient as jest.Mock).mockReturnValue({ listClient: getListClientMock(), exceptionsClient: getExceptionListClientMock(), @@ -124,7 +114,6 @@ describe('rules_notification_alert_type', () => { exceptionsWithValueLists: [], }); (searchAfterAndBulkCreate as jest.Mock).mockClear(); - (getGapMaxCatchupRatio as jest.Mock).mockClear(); (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue({ success: true, searchAfterTimes: [], @@ -187,23 +176,12 @@ describe('rules_notification_alert_type', () => { describe('executor', () => { it('should warn about the gap between runs if gap is very large', async () => { - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(100, 'm')); - (getGapMaxCatchupRatio as jest.Mock).mockReturnValue({ - maxCatchup: 4, - ratio: 20, - gapDiffInUnits: 95, - }); + payload.previousStartedAt = moment().subtract(100, 'm').toDate(); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); - expect(logger.warn.mock.calls[0][0]).toContain( - '2 hours (6000000ms) has passed since last rule execution, and signals may have been missed.' - ); expect(ruleStatusService.error).toHaveBeenCalled(); - expect(ruleStatusService.error.mock.calls[0][0]).toContain( - '2 hours (6000000ms) has passed since last rule execution, and signals may have been missed.' - ); expect(ruleStatusService.error.mock.calls[0][1]).toEqual({ - gap: '2 hours', + gap: 'an hour', }); }); @@ -257,12 +235,7 @@ describe('rules_notification_alert_type', () => { }); it('should NOT warn about the gap between runs if gap small', async () => { - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(1, 'm')); - (getGapMaxCatchupRatio as jest.Mock).mockReturnValue({ - maxCatchup: 1, - ratio: 1, - gapDiffInUnits: 1, - }); + payload.previousStartedAt = moment().subtract(10, 'm').toDate(); await alert.executor(payload); expect(logger.warn).toHaveBeenCalledTimes(0); expect(ruleStatusService.error).toHaveBeenCalledTimes(0); @@ -450,6 +423,7 @@ describe('rules_notification_alert_type', () => { const ruleAlert = getMlResult(); ruleAlert.params.anomalyThreshold = undefined; payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); expect(logger.error.mock.calls[0][0]).toContain( @@ -460,6 +434,7 @@ describe('rules_notification_alert_type', () => { it('should throw an error if Machine learning job summary was null', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([]); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); @@ -473,6 +448,7 @@ describe('rules_notification_alert_type', () => { it('should log an error if Machine learning job was not started', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', @@ -518,6 +494,7 @@ describe('rules_notification_alert_type', () => { it('should call ruleStatusService.success if signals were created', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', @@ -544,6 +521,7 @@ describe('rules_notification_alert_type', () => { it('should not call checkPrivileges if ML rule', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 98c9dd41d179c..2025ba512cb65 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -37,11 +37,8 @@ import { WrappedSignalHit, } from './types'; import { - getGapBetweenRuns, getListsClient, getExceptions, - getGapMaxCatchupRatio, - MAX_RULE_GAP_RATIO, wrapSignal, createErrorsFromShard, createSearchAfterReturnType, @@ -50,6 +47,7 @@ import { checkPrivileges, hasTimestampFields, hasReadIndexPrivileges, + getRuleRangeTuples, } from './utils'; import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; @@ -230,29 +228,24 @@ export const signalRulesAlertType = ({ } catch (exc) { logger.error(buildRuleMessage(`Check privileges failed to execute ${exc}`)); } - - const gap = getGapBetweenRuns({ previousStartedAt, interval, from, to }); - if (gap != null && gap.asMilliseconds() > 0) { - const fromUnit = from[from.length - 1]; - const { ratio } = getGapMaxCatchupRatio({ - logger, - buildRuleMessage, - previousStartedAt, - ruleParamsFrom: from, - interval, - unit: fromUnit, - }); - if (ratio && ratio >= MAX_RULE_GAP_RATIO) { - const gapString = gap.humanize(); - const gapMessage = buildRuleMessage( - `${gapString} (${gap.asMilliseconds()}ms) has passed since last rule execution, and signals may have been missed.`, - 'Consider increasing your look behind time or adding more Kibana instances.' - ); - logger.warn(gapMessage); - - hasError = true; - await ruleStatusService.error(gapMessage, { gap: gapString }); - } + const { tuples, remainingGap } = getRuleRangeTuples({ + logger, + previousStartedAt, + from, + to, + interval, + maxSignals, + buildRuleMessage, + }); + if (remainingGap.asMilliseconds() > 0) { + const gapString = remainingGap.humanize(); + const gapMessage = buildRuleMessage( + `${gapString} (${remainingGap.asMilliseconds()}ms) were not queried between this rule execution and the last execution, so signals may have been missed.`, + 'Consider increasing your look behind time or adding more Kibana instances.' + ); + logger.warn(gapMessage); + hasError = true; + await ruleStatusService.error(gapMessage, { gap: gapString }); } try { const { listClient, exceptionsClient } = getListsClient({ @@ -479,6 +472,7 @@ export const signalRulesAlertType = ({ } const inputIndex = await getInputIndex(services, version, index); result = await createThreatSignals({ + tuples, threatMapping, query, inputIndex, @@ -489,8 +483,6 @@ export const signalRulesAlertType = ({ savedId, services, exceptionItems: exceptionItems ?? [], - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -531,8 +523,7 @@ export const signalRulesAlertType = ({ }); result = await searchAfterAndBulkCreate({ - gap, - previousStartedAt, + tuples, listClient, exceptionsList: exceptionItems ?? [], ruleParams: params, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts index cdbafe692c630..7d6cd655e336d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; +import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants'; import { SignalSearchResponse, SignalsEnrichment } from '../types'; import { enrichSignalThreatMatches } from './enrich_signal_threat_matches'; import { getThreatList } from './get_threat_list'; @@ -52,7 +52,9 @@ export const buildThreatEnrichment = ({ return threatResponse.hits.hits; }; - const defaultedIndicatorPath = threatIndicatorPath ? threatIndicatorPath : DEFAULT_INDICATOR_PATH; + const defaultedIndicatorPath = threatIndicatorPath + ? threatIndicatorPath + : DEFAULT_INDICATOR_SOURCE_PATH; return (signals: SignalSearchResponse): Promise => enrichSignalThreatMatches(signals, getMatchedThreats, defaultedIndicatorPath); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index ba428bc077125..d9c72f7f95679 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -13,6 +13,7 @@ import { CreateThreatSignalOptions } from './types'; import { SearchAfterAndBulkCreateReturnType } from '../types'; export const createThreatSignal = async ({ + tuples, threatMapping, threatEnrichment, query, @@ -23,8 +24,6 @@ export const createThreatSignal = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -80,8 +79,7 @@ export const createThreatSignal = async ({ ); const result = await searchAfterAndBulkCreate({ - gap, - previousStartedAt, + tuples, listClient, exceptionsList: exceptionItems, ruleParams: params, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index e45aea29c423f..854c2b8f3fdc1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -15,6 +15,7 @@ import { combineConcurrentResults } from './utils'; import { buildThreatEnrichment } from './build_threat_enrichment'; export const createThreatSignals = async ({ + tuples, threatMapping, query, inputIndex, @@ -24,8 +25,6 @@ export const createThreatSignals = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -111,6 +110,7 @@ export const createThreatSignals = async ({ const concurrentSearchesPerformed = chunks.map>( (slicedChunk) => createThreatSignal({ + tuples, threatEnrichment, threatMapping, query, @@ -121,8 +121,6 @@ export const createThreatSignals = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts index f98f0c88a2dfa..b77e8228e72d8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts @@ -6,7 +6,7 @@ */ import { get } from 'lodash'; -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; +import { INDICATOR_DESTINATION_PATH } from '../../../../../common/constants'; import { getThreatListItemMock } from './build_threat_mapping_filter.mock'; import { @@ -75,8 +75,10 @@ describe('groupAndMergeSignalMatches', () => { describe('buildMatchedIndicator', () => { let threats: ThreatListItem[]; let queries: ThreatMatchNamedQuery[]; + let indicatorPath: string; beforeEach(() => { + indicatorPath = 'threat.indicator'; threats = [ getThreatListItemMock({ _id: '123', @@ -94,7 +96,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries: [], threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([]); @@ -104,7 +106,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.atomic')).toEqual('domain_1'); @@ -114,7 +116,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.field')).toEqual('event.field'); @@ -124,7 +126,7 @@ describe('buildMatchedIndicator', () => { const [indicator] = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(get(indicator, 'matched.type')).toEqual('type_1'); @@ -153,7 +155,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toHaveLength(queries.length); @@ -163,7 +165,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -228,7 +230,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -253,7 +255,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -285,7 +287,7 @@ describe('buildMatchedIndicator', () => { const indicators = buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }); expect(indicators).toEqual([ @@ -317,7 +319,7 @@ describe('buildMatchedIndicator', () => { buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }) ).toThrowError('Expected indicator field to be an object, but found: not an object'); }); @@ -338,7 +340,7 @@ describe('buildMatchedIndicator', () => { buildMatchedIndicator({ queries, threats, - indicatorPath: DEFAULT_INDICATOR_PATH, + indicatorPath, }) ).toThrowError('Expected indicator field to be an object, but found: not an object'); }); @@ -347,8 +349,10 @@ describe('buildMatchedIndicator', () => { describe('enrichSignalThreatMatches', () => { let getMatchedThreats: GetMatchedThreats; let matchedQuery: string; + let indicatorPath: string; beforeEach(() => { + indicatorPath = 'threat.indicator'; getMatchedThreats = async () => [ getThreatListItemMock({ _id: '123', @@ -367,7 +371,7 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); expect(enrichedSignals.hits.hits).toEqual([]); @@ -382,10 +386,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { existing: 'indicator' }, @@ -407,10 +411,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { @@ -428,10 +432,10 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { existing: 'indicator' }, @@ -451,7 +455,7 @@ describe('enrichSignalThreatMatches', () => { }); const signals = getSignalsResponseMock([signalHit]); await expect(() => - enrichSignalThreatMatches(signals, getMatchedThreats, DEFAULT_INDICATOR_PATH) + enrichSignalThreatMatches(signals, getMatchedThreats, indicatorPath) ).rejects.toThrowError('Expected threat field to be an object, but found: whoops'); }); @@ -487,7 +491,7 @@ describe('enrichSignalThreatMatches', () => { 'custom_threat.custom_indicator' ); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { @@ -530,13 +534,13 @@ describe('enrichSignalThreatMatches', () => { const enrichedSignals = await enrichSignalThreatMatches( signals, getMatchedThreats, - DEFAULT_INDICATOR_PATH + indicatorPath ); expect(enrichedSignals.hits.total).toEqual(expect.objectContaining({ value: 1 })); expect(enrichedSignals.hits.hits).toHaveLength(1); const [enrichedHit] = enrichedSignals.hits.hits; - const indicators = get(enrichedHit._source, DEFAULT_INDICATOR_PATH); + const indicators = get(enrichedHit._source, INDICATOR_DESTINATION_PATH); expect(indicators).toEqual([ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts index 761a58224fac5..3c8b80886cabe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts @@ -6,7 +6,6 @@ */ import { get, isObject } from 'lodash'; -import { DEFAULT_INDICATOR_PATH } from '../../../../../common/constants'; import type { SignalSearchResponse, SignalSourceHit } from '../types'; import type { @@ -92,7 +91,11 @@ export const enrichSignalThreatMatches = async ( if (!isObject(threat)) { throw new Error(`Expected threat field to be an object, but found: ${threat}`); } - const existingIndicatorValue = get(signalHit._source, DEFAULT_INDICATOR_PATH) ?? []; + // We are not using INDICATOR_DESTINATION_PATH here because the code above + // and below make assumptions about its current value, 'threat.indicator', + // and making this code dynamic on an arbitrary path would introduce several + // new issues. + const existingIndicatorValue = get(signalHit._source, 'threat.indicator') ?? []; const existingIndicators = [existingIndicatorValue].flat(); // ensure indicators is an array return { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index a022cbbdd4042..1c35a5af09b38 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -6,7 +6,6 @@ */ import { SearchResponse } from 'elasticsearch'; -import { Duration } from 'moment'; import { ListClient } from '../../../../../../lists/server'; import { @@ -34,11 +33,12 @@ import { ILegacyScopedClusterClient, Logger } from '../../../../../../../../src/ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; -import { SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; +import { RuleRangeTuple, SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; export type SortOrderOrUndefined = 'asc' | 'desc' | undefined; export interface CreateThreatSignalsOptions { + tuples: RuleRangeTuple[]; threatMapping: ThreatMapping; query: string; inputIndex: string[]; @@ -48,8 +48,6 @@ export interface CreateThreatSignalsOptions { savedId: string | undefined; services: AlertServices; exceptionItems: ExceptionListItemSchema[]; - gap: Duration | null; - previousStartedAt: Date | null; listClient: ListClient; logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; @@ -79,6 +77,7 @@ export interface CreateThreatSignalsOptions { } export interface CreateThreatSignalOptions { + tuples: RuleRangeTuple[]; threatMapping: ThreatMapping; threatEnrichment: SignalsEnrichment; query: string; @@ -89,8 +88,6 @@ export interface CreateThreatSignalOptions { savedId: string | undefined; services: AlertServices; exceptionItems: ExceptionListItemSchema[]; - gap: Duration | null; - previousStartedAt: Date | null; listClient: ListClient; logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index e5ca1f6a60456..f759da31566e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -73,6 +73,12 @@ export interface ThresholdSignalHistory { [hash: string]: ThresholdSignalHistoryRecord; } +export interface RuleRangeTuple { + to: moment.Moment; + from: moment.Moment; + maxSignals: number; +} + export interface SignalSource { [key: string]: SearchTypes; // TODO: SignalSource is being used as the type for documents matching detection engine queries, but they may not @@ -251,8 +257,11 @@ export interface QueryFilter { export type SignalsEnrichment = (signals: SignalSearchResponse) => Promise; export interface SearchAfterAndBulkCreateParams { - gap: moment.Duration | null; - previousStartedAt: Date | null | undefined; + tuples: Array<{ + to: moment.Moment; + from: moment.Moment; + maxSignals: number; + }>; ruleParams: RuleTypeParams; services: AlertServices; listClient: ListClient; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 7888bb6deaab7..0a581816ee82f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -25,10 +25,10 @@ import { parseInterval, getDriftTolerance, getGapBetweenRuns, - getGapMaxCatchupRatio, + getNumCatchupIntervals, errorAggregator, getListsClient, - getSignalTimeTuples, + getRuleRangeTuples, getExceptions, hasTimestampFields, wrapBuildingBlocks, @@ -109,7 +109,7 @@ describe('utils', () => { expect(duration?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); }); - test('it returns null given an invalid duration', () => { + test('it throws given an invalid duration', () => { const duration = parseInterval('junk'); expect(duration).toBeNull(); }); @@ -148,7 +148,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-6m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -158,7 +158,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-5m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift?.asMilliseconds()).toEqual(0); }); @@ -167,7 +167,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); @@ -177,7 +177,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now', - interval: moment.duration(0, 'milliseconds'), + intervalDuration: moment.duration(0, 'milliseconds'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(10, 'minutes').asMilliseconds()); @@ -187,7 +187,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'invalid', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -197,7 +197,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: '10m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -207,7 +207,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now-1m', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(4, 'minutes').asMilliseconds()); @@ -217,7 +217,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: moment().subtract(10, 'minutes').toISOString(), to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); @@ -227,7 +227,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-6m', to: moment().toISOString(), - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -238,7 +238,7 @@ describe('utils', () => { test('it returns a gap of 0 when "from" and interval match each other and the previous started was from the previous interval time', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-5m', to: 'now', now: nowDate.clone(), @@ -250,7 +250,7 @@ describe('utils', () => { test('it returns a negative gap of 1 minute when "from" overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -262,7 +262,7 @@ describe('utils', () => { test('it returns a negative gap of 5 minutes when "from" overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-10m', to: 'now', now: nowDate.clone(), @@ -274,7 +274,7 @@ describe('utils', () => { test('it returns a negative gap of 1 minute when "from" overlaps to by 1 minute and the previousStartedAt was 10 minutes ago and so was the interval', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(10, 'minutes').toDate(), - interval: '10m', + intervalDuration: moment.duration(10, 'minutes'), from: 'now-11m', to: 'now', now: nowDate.clone(), @@ -286,7 +286,7 @@ describe('utils', () => { test('it returns a gap of only -30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is 30 seconds more', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').subtract(30, 'seconds').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -298,7 +298,7 @@ describe('utils', () => { test('it returns an exact 0 gap when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(6, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -310,7 +310,7 @@ describe('utils', () => { test('it returns a gap of 30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute and 30 seconds late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(6, 'minutes').subtract(30, 'seconds').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -322,7 +322,7 @@ describe('utils', () => { test('it returns a gap of 1 minute when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is two minutes late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -331,32 +331,21 @@ describe('utils', () => { expect(gap?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); }); - test('it returns null if given a previousStartedAt of null', () => { + test('it returns 0 if given a previousStartedAt of null', () => { const gap = getGapBetweenRuns({ previousStartedAt: null, - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-5m', to: 'now', now: nowDate.clone(), }); - expect(gap).toBeNull(); - }); - - test('it returns null if the interval is an invalid string such as "invalid"', () => { - const gap = getGapBetweenRuns({ - previousStartedAt: nowDate.clone().toDate(), - interval: 'invalid', // if not set to "x" where x is an interval such as 6m - from: 'now-5m', - to: 'now', - now: nowDate.clone(), - }); - expect(gap).toBeNull(); + expect(gap.asMilliseconds()).toEqual(0); }); test('it returns the expected result when "from" is an invalid string such as "invalid"', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'invalid', to: 'now', now: nowDate.clone(), @@ -368,7 +357,7 @@ describe('utils', () => { test('it returns the expected result when "to" is an invalid string such as "invalid"', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'invalid', now: nowDate.clone(), @@ -609,134 +598,116 @@ describe('utils', () => { }); }); - describe('getSignalTimeTuples', () => { + describe('getRuleRangeTuples', () => { test('should return a single tuple if no gap', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: null, previousStartedAt: moment().subtract(30, 's').toDate(), interval: '30s', - ruleParamsFrom: 'now-30s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-30s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - const someTuple = someTuples[0]; + const someTuple = tuples[0]; expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30); + expect(tuples.length).toEqual(1); + expect(remainingGap.asMilliseconds()).toEqual(0); + }); + + test('should return a single tuple if malformed interval prevents gap calculation', () => { + const { tuples, remainingGap } = getRuleRangeTuples({ + logger: mockLogger, + previousStartedAt: moment().subtract(30, 's').toDate(), + interval: 'invalid', + from: 'now-30s', + to: 'now', + maxSignals: 20, + buildRuleMessage, + }); + const someTuple = tuples[0]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30); + expect(tuples.length).toEqual(1); + expect(remainingGap.asMilliseconds()).toEqual(0); }); test('should return two tuples if gap and previouslyStartedAt', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(10, 's'), previousStartedAt: moment().subtract(65, 's').toDate(), interval: '50s', - ruleParamsFrom: 'now-55s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-55s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - const someTuple = someTuples[1]; - expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(10); + const someTuple = tuples[1]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(55); + expect(remainingGap.asMilliseconds()).toEqual(0); }); test('should return five tuples when give long gap', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(65, 's'), // 64 is 5 times the interval + lookback, which will trigger max lookback - previousStartedAt: moment().subtract(65, 's').toDate(), + previousStartedAt: moment().subtract(65, 's').toDate(), // 64 is 5 times the interval + lookback, which will trigger max lookback interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-13s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - expect(someTuples.length).toEqual(5); - someTuples.forEach((item, index) => { + expect(tuples.length).toEqual(5); + tuples.forEach((item, index) => { if (index === 0) { return; } - expect(moment(item.to).diff(moment(item.from), 's')).toEqual(10); + expect(moment(item.to).diff(moment(item.from), 's')).toEqual(13); + expect(item.to.diff(tuples[index - 1].to, 's')).toEqual(-10); + expect(item.from.diff(tuples[index - 1].from, 's')).toEqual(-10); }); + expect(remainingGap.asMilliseconds()).toEqual(12000); }); - // this tests if calculatedFrom in utils.ts:320 parses an int and not a float - // if we don't parse as an int, then dateMath.parse will fail - // as it doesn't support parsing `now-67.549`, it only supports ints like `now-67`. - test('should return five tuples when given a gap with a decimal to ensure no parsing errors', () => { - const someTuples = getSignalTimeTuples({ + test('should return a single tuple when give a negative gap (rule ran sooner than expected)', () => { + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(67549, 'ms'), // 64 is 5 times the interval + lookback, which will trigger max lookback - previousStartedAt: moment().subtract(67549, 'ms').toDate(), - interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, - buildRuleMessage, - }); - expect(someTuples.length).toEqual(5); - }); - - test('should return single tuples when give a negative gap (rule ran sooner than expected)', () => { - const someTuples = getSignalTimeTuples({ - logger: mockLogger, - gap: moment.duration(-15, 's'), // 64 is 5 times the interval + lookback, which will trigger max lookback previousStartedAt: moment().subtract(-15, 's').toDate(), interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-13s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - expect(someTuples.length).toEqual(1); - const someTuple = someTuples[0]; + expect(tuples.length).toEqual(1); + const someTuple = tuples[0]; expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(13); + expect(remainingGap.asMilliseconds()).toEqual(0); }); }); describe('getMaxCatchupRatio', () => { - test('should return null if rule has never run before', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: null, - interval: '30s', - ruleParamsFrom: 'now-30s', - buildRuleMessage, - unit: 's', + test('should return 0 if gap is 0', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(0), + intervalDuration: moment.duration(11000), }); - expect(maxCatchup).toBeNull(); - expect(ratio).toBeNull(); - expect(gapDiffInUnits).toBeNull(); + expect(catchup).toEqual(0); }); - test('should should have non-null values when gap is present', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: moment().subtract(65, 's').toDate(), - interval: '50s', - ruleParamsFrom: 'now-55s', - buildRuleMessage, - unit: 's', + test('should return 1 if gap is in (0, intervalDuration]', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(10000), + intervalDuration: moment.duration(10000), }); - expect(maxCatchup).toEqual(0.2); - expect(ratio).toEqual(0.2); - expect(gapDiffInUnits).toEqual(10); + expect(catchup).toEqual(1); }); - // when a rule runs sooner than expected we don't - // consider that a gap as that is a very rare circumstance - test('should return null when given a negative gap (rule ran sooner than expected)', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: moment().subtract(-15, 's').toDate(), - interval: '10s', - ruleParamsFrom: 'now-13s', - buildRuleMessage, - unit: 's', + test('should round up return value', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(15000), + intervalDuration: moment.duration(11000), }); - expect(maxCatchup).toBeNull(); - expect(ratio).toBeNull(); - expect(gapDiffInUnits).toBeNull(); + expect(catchup).toEqual(2); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 58bf22be97bf8..2b306cd2a8d9d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -29,12 +29,12 @@ import { ListArray } from '../../../../common/detection_engine/schemas/types/lis import { BulkResponse, BulkResponseErrorAggregation, - isValidUnit, SignalHit, SearchAfterAndBulkCreateReturnType, SignalSearchResponse, Signal, WrappedSignalHit, + RuleRangeTuple, } from './types'; import { BuildRuleMessage } from './rule_messages'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; @@ -163,82 +163,21 @@ export const checkPrivileges = async ( }, }); -export const getGapMaxCatchupRatio = ({ - logger, - previousStartedAt, - unit, - buildRuleMessage, - ruleParamsFrom, - interval, +export const getNumCatchupIntervals = ({ + gap, + intervalDuration, }: { - logger: Logger; - ruleParamsFrom: string; - previousStartedAt: Date | null | undefined; - interval: string; - buildRuleMessage: BuildRuleMessage; - unit: string; -}): { - maxCatchup: number | null; - ratio: number | null; - gapDiffInUnits: number | null; -} => { - if (previousStartedAt == null) { - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - if (!isValidUnit(unit)) { - logger.error(buildRuleMessage(`unit: ${unit} failed isValidUnit check`)); - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - /* - we need the total duration from now until the last time the rule ran. - the next few lines can be summed up as calculating - "how many second | minutes | hours have passed since the last time this ran?" - */ - const nowToGapDiff = moment.duration(moment().diff(previousStartedAt)); - // rule ran early, no gap - if (shorthandMap[unit].asFn(nowToGapDiff) < 0) { - // rule ran early, no gap - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - const calculatedFrom = `now-${ - parseInt(shorthandMap[unit].asFn(nowToGapDiff).toString(), 10) + unit - }`; - logger.debug(buildRuleMessage(`calculatedFrom: ${calculatedFrom}`)); - - const intervalMoment = moment.duration(parseInt(interval, 10), unit); - logger.debug(buildRuleMessage(`intervalMoment: ${shorthandMap[unit].asFn(intervalMoment)}`)); - const calculatedFromAsMoment = dateMath.parse(calculatedFrom); - const dateMathRuleParamsFrom = dateMath.parse(ruleParamsFrom); - if (dateMathRuleParamsFrom != null && intervalMoment != null) { - const momentUnit = shorthandMap[unit].momentString as moment.DurationInputArg2; - const gapDiffInUnits = dateMathRuleParamsFrom.diff(calculatedFromAsMoment, momentUnit); - - const ratio = gapDiffInUnits / shorthandMap[unit].asFn(intervalMoment); - - // maxCatchup is to ensure we are not trying to catch up too far back. - // This allows for a maximum of 4 consecutive rule execution misses - // to be included in the number of signals generated. - const maxCatchup = ratio < MAX_RULE_GAP_RATIO ? ratio : MAX_RULE_GAP_RATIO; - return { maxCatchup, ratio, gapDiffInUnits }; + gap: moment.Duration; + intervalDuration: moment.Duration; +}): number => { + if (gap.asMilliseconds() <= 0 || intervalDuration.asMilliseconds() <= 0) { + return 0; } - logger.error(buildRuleMessage('failed to parse calculatedFrom and intervalMoment')); - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; + const ratio = Math.ceil(gap.asMilliseconds() / intervalDuration.asMilliseconds()); + // maxCatchup is to ensure we are not trying to catch up too far back. + // This allows for a maximum of 4 consecutive rule execution misses + // to be included in the number of signals generated. + return ratio < MAX_RULE_GAP_RATIO ? ratio : MAX_RULE_GAP_RATIO; }; export const getListsClient = ({ @@ -396,50 +335,40 @@ export const parseInterval = (intervalString: string): moment.Duration | null => export const getDriftTolerance = ({ from, to, - interval, + intervalDuration, now = moment(), }: { from: string; to: string; - interval: moment.Duration; + intervalDuration: moment.Duration; now?: moment.Moment; -}): moment.Duration | null => { +}): moment.Duration => { const toDate = parseScheduleDates(to) ?? now; const fromDate = parseScheduleDates(from) ?? dateMath.parse('now-6m'); const timeSegment = toDate.diff(fromDate); const duration = moment.duration(timeSegment); - if (duration !== null) { - return duration.subtract(interval); - } else { - return null; - } + return duration.subtract(intervalDuration); }; export const getGapBetweenRuns = ({ previousStartedAt, - interval, + intervalDuration, from, to, now = moment(), }: { previousStartedAt: Date | undefined | null; - interval: string; + intervalDuration: moment.Duration; from: string; to: string; now?: moment.Moment; -}): moment.Duration | null => { +}): moment.Duration => { if (previousStartedAt == null) { - return null; - } - const intervalDuration = parseInterval(interval); - if (intervalDuration == null) { - return null; - } - const driftTolerance = getDriftTolerance({ from, to, interval: intervalDuration }); - if (driftTolerance == null) { - return null; + return moment.duration(0); } + const driftTolerance = getDriftTolerance({ from, to, intervalDuration }); + const diff = moment.duration(now.diff(previousStartedAt)); const drift = diff.subtract(intervalDuration); return drift.subtract(driftTolerance); @@ -489,135 +418,103 @@ export const errorAggregator = ( }, Object.create(null)); }; -/** - * Determines the number of time intervals to search if gap is present - * along with new maxSignals per time interval. - * @param logger Logger - * @param ruleParamsFrom string representing the rules 'from' property - * @param ruleParamsTo string representing the rules 'to' property - * @param ruleParamsMaxSignals int representing the maxSignals property on the rule (usually unmodified at 100) - * @param gap moment.Duration representing a gap in since the last time the rule ran - * @param previousStartedAt Date at which the rule last ran - * @param interval string the interval which the rule runs - * @param buildRuleMessage function provides meta information for logged event - */ -export const getSignalTimeTuples = ({ +export const getRuleRangeTuples = ({ logger, - ruleParamsFrom, - ruleParamsTo, - ruleParamsMaxSignals, - gap, previousStartedAt, + from, + to, interval, + maxSignals, buildRuleMessage, }: { logger: Logger; - ruleParamsFrom: string; - ruleParamsTo: string; - ruleParamsMaxSignals: number; - gap: moment.Duration | null; previousStartedAt: Date | null | undefined; + from: string; + to: string; interval: string; - buildRuleMessage: BuildRuleMessage; -}): Array<{ - to: moment.Moment | undefined; - from: moment.Moment | undefined; maxSignals: number; -}> => { - let totalToFromTuples: Array<{ - to: moment.Moment | undefined; - from: moment.Moment | undefined; - maxSignals: number; - }> = []; - if (gap != null && gap.valueOf() > 0 && previousStartedAt != null) { - const fromUnit = ruleParamsFrom[ruleParamsFrom.length - 1]; - if (isValidUnit(fromUnit)) { - const unit = fromUnit; // only seconds (s), minutes (m) or hours (h) - - /* - we need the total duration from now until the last time the rule ran. - the next few lines can be summed up as calculating - "how many second | minutes | hours have passed since the last time this ran?" - */ - const nowToGapDiff = moment.duration(moment().diff(previousStartedAt)); - const calculatedFrom = `now-${ - parseInt(shorthandMap[unit].asFn(nowToGapDiff).toString(), 10) + unit - }`; - logger.debug(buildRuleMessage(`calculatedFrom: ${calculatedFrom}`)); - - const intervalMoment = moment.duration(parseInt(interval, 10), unit); - logger.debug(buildRuleMessage(`intervalMoment: ${shorthandMap[unit].asFn(intervalMoment)}`)); - const momentUnit = shorthandMap[unit].momentString as moment.DurationInputArg2; - // maxCatchup is to ensure we are not trying to catch up too far back. - // This allows for a maximum of 4 consecutive rule execution misses - // to be included in the number of signals generated. - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger, - buildRuleMessage, - previousStartedAt, - unit, - ruleParamsFrom, - interval, - }); - logger.debug(buildRuleMessage(`maxCatchup: ${maxCatchup}, ratio: ${ratio}`)); - if (maxCatchup == null || ratio == null || gapDiffInUnits == null) { - throw new Error( - buildRuleMessage('failed to calculate maxCatchup, ratio, or gapDiffInUnits') - ); - } - let tempTo = dateMath.parse(ruleParamsFrom); - if (tempTo == null) { - // return an error - throw new Error(buildRuleMessage('dateMath parse failed')); - } - - let beforeMutatedFrom: moment.Moment | undefined; - while (totalToFromTuples.length < maxCatchup) { - // if maxCatchup is less than 1, we calculate the 'from' differently - // and maxSignals becomes some less amount of maxSignals - // in order to maintain maxSignals per full rule interval. - if (maxCatchup > 0 && maxCatchup < 1) { - totalToFromTuples.push({ - to: tempTo.clone(), - from: tempTo.clone().subtract(gapDiffInUnits, momentUnit), - maxSignals: ruleParamsMaxSignals * maxCatchup, - }); - break; - } - const beforeMutatedTo = tempTo.clone(); - - // moment.subtract mutates the moment so we need to clone again.. - beforeMutatedFrom = tempTo.clone().subtract(intervalMoment, momentUnit); - const tuple = { - to: beforeMutatedTo, - from: beforeMutatedFrom, - maxSignals: ruleParamsMaxSignals, - }; - totalToFromTuples = [...totalToFromTuples, tuple]; - tempTo = beforeMutatedFrom; - } - totalToFromTuples = [ - { - to: dateMath.parse(ruleParamsTo), - from: dateMath.parse(ruleParamsFrom), - maxSignals: ruleParamsMaxSignals, - }, - ...totalToFromTuples, - ]; - } - } else { - totalToFromTuples = [ - { - to: dateMath.parse(ruleParamsTo), - from: dateMath.parse(ruleParamsFrom), - maxSignals: ruleParamsMaxSignals, - }, - ]; + buildRuleMessage: BuildRuleMessage; +}) => { + const originalTo = dateMath.parse(to); + const originalFrom = dateMath.parse(from); + if (originalTo == null || originalFrom == null) { + throw new Error(buildRuleMessage('dateMath parse failed')); + } + const tuples = [ + { + to: originalTo, + from: originalFrom, + maxSignals, + }, + ]; + const intervalDuration = parseInterval(interval); + if (intervalDuration == null) { + logger.error(`Failed to compute gap between rule runs: could not parse rule interval`); + return { tuples, remainingGap: moment.duration(0) }; } - logger.debug( - buildRuleMessage(`totalToFromTuples: ${JSON.stringify(totalToFromTuples, null, 4)}`) + const gap = getGapBetweenRuns({ previousStartedAt, intervalDuration, from, to }); + const catchup = getNumCatchupIntervals({ + gap, + intervalDuration, + }); + const catchupTuples = getCatchupTuples({ + to: originalTo, + from: originalFrom, + ruleParamsMaxSignals: maxSignals, + catchup, + intervalDuration, + }); + tuples.push(...catchupTuples); + // Each extra tuple adds one extra intervalDuration to the time range this rule will cover. + const remainingGapMilliseconds = Math.max( + gap.asMilliseconds() - catchup * intervalDuration.asMilliseconds(), + 0 ); - return totalToFromTuples; + return { tuples, remainingGap: moment.duration(remainingGapMilliseconds) }; +}; + +/** + * Creates rule range tuples needed to cover gaps since the last rule run. + * @param to moment.Moment representing the rules 'to' property + * @param from moment.Moment representing the rules 'from' property + * @param ruleParamsMaxSignals int representing the maxSignals property on the rule (usually unmodified at 100) + * @param catchup number the number of additional rule run intervals to add + * @param intervalDuration moment.Duration the interval which the rule runs + */ +export const getCatchupTuples = ({ + to, + from, + ruleParamsMaxSignals, + catchup, + intervalDuration, +}: { + to: moment.Moment; + from: moment.Moment; + ruleParamsMaxSignals: number; + catchup: number; + intervalDuration: moment.Duration; +}): RuleRangeTuple[] => { + const catchupTuples: RuleRangeTuple[] = []; + const intervalInMilliseconds = intervalDuration.asMilliseconds(); + let currentTo = to; + let currentFrom = from; + // This loop will create tuples with overlapping time ranges, the same way rule runs have overlapping time + // ranges due to the additional lookback. We could choose to create tuples that don't overlap here by using the + // "from" value from one tuple as "to" in the next one, however, the overlap matters for rule types like EQL and + // threshold rules that look for sets of documents within the query. Thus we keep the overlap so that these + // extra tuples behave as similarly to the regular rule runs as possible. + while (catchupTuples.length < catchup) { + const nextTo = currentTo.clone().subtract(intervalInMilliseconds); + const nextFrom = currentFrom.clone().subtract(intervalInMilliseconds); + catchupTuples.push({ + to: nextTo, + from: nextFrom, + maxSignals: ruleParamsMaxSignals, + }); + currentTo = nextTo; + currentFrom = nextFrom; + } + return catchupTuples; }; /** diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7f2a8e986b5d4..192b0f05fa04b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5949,19 +5949,10 @@ "xpack.canvas.functions.ltHelpText": "{CONTEXT} が引数よりも小さいかを戻します。", "xpack.canvas.functions.mapCenter.args.latHelpText": "マップの中央の緯度", "xpack.canvas.functions.mapCenterHelpText": "マップの中央座標とズームレベルのオブジェクトに戻ります。", - "xpack.canvas.functions.mapColumn.args.expressionHelpText": "単一行 {DATATABLE} として各行に渡される {CANVAS} 表現です。", - "xpack.canvas.functions.mapColumn.args.nameHelpText": "結果の列の名前です。", - "xpack.canvas.functions.mapColumnHelpText": "他の列の結果として計算された列を追加します。引数が指定された場合のみ変更が加えられます。{alterColumnFn}と{staticColumnFn}もご参照ください。", "xpack.canvas.functions.markdown.args.contentHelpText": "{MARKDOWN} を含むテキストの文字列です。連結させるには、{stringFn} 関数を複数回渡します。", "xpack.canvas.functions.markdown.args.fontHelpText": "コンテンツの {CSS} フォントプロパティです。たとえば、{fontFamily} または {fontWeight} です。", "xpack.canvas.functions.markdown.args.openLinkHelpText": "新しいタブでリンクを開くためのtrue/false値。デフォルト値は「false」です。「true」に設定するとすべてのリンクが新しいタブで開くようになります。", "xpack.canvas.functions.markdownHelpText": "{MARKDOWN} テキストをレンダリングするエレメントを追加します。ヒント:単一の数字、メトリック、テキストの段落には {markdownFn} 関数を使います。", - "xpack.canvas.functions.math.args.expressionHelpText": "評価された {TINYMATH} 表現です。{TINYMATH_URL} をご覧ください。", - "xpack.canvas.functions.math.emptyDatatableErrorMessage": "空のデータベース", - "xpack.canvas.functions.math.emptyExpressionErrorMessage": "空の表現", - "xpack.canvas.functions.math.executionFailedErrorMessage": "数式の実行に失敗しました。列名を確認してください", - "xpack.canvas.functions.math.tooManyResultsErrorMessage": "式は 1 つの数字を返す必要があります。表現を {mean} または {sum} で囲んでみてください", - "xpack.canvas.functions.mathHelpText": "{TYPE_NUMBER}または{DATATABLE}を{CONTEXT}として使用して、{TINYMATH}数式を解釈します。{DATATABLE}列は列名で表示されます。{CONTEXT}が数字の場合は、{value}と表示されます。", "xpack.canvas.functions.metric.args.labelFontHelpText": "ラベルの {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", "xpack.canvas.functions.metric.args.labelHelpText": "メトリックを説明するテキストです。", "xpack.canvas.functions.metric.args.metricFontHelpText": "メトリックの {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", @@ -9475,7 +9466,6 @@ "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotFieldTitle": "検索可能スナップショット", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutBody": "検索可能なスナップショットを作成するには、エンタープライズライセンスが必要です。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutTitle": "エンタープライズライセンスが必要です", - "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotNoRolloverCalloutBody": "ロールオーバーがホットフェーズで無効な時には、検索可能なスナップショットを作成できません。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotRepoRequiredError": "スナップショットリポジトリ名が必要です。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotsToggleLabel": "検索可能スナップショットを作成", "xpack.indexLifecycleMgmt.editPolicy.showPolicyJsonButto": "リクエストを表示", @@ -21502,10 +21492,6 @@ "xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel": "インデックス", "xpack.upgradeAssistant.checkupTab.deprecations.warningActionTooltip": "アップグレード前にこの問題を解決することをお勧めしますが、必須ではありません。", "xpack.upgradeAssistant.checkupTab.deprecations.warningLabel": "警告", - "xpack.upgradeAssistant.checkupTab.fixMetricbeatIndexButton.failedLabel": "失敗", - "xpack.upgradeAssistant.checkupTab.fixMetricbeatIndexButton.fixedLabel": "修正済み", - "xpack.upgradeAssistant.checkupTab.fixMetricbeatIndexButton.fixingLabel": "修正中…", - "xpack.upgradeAssistant.checkupTab.fixMetricbeatIndexButton.reindexLabel": "修正", "xpack.upgradeAssistant.checkupTab.indexLabel": "インデックス", "xpack.upgradeAssistant.checkupTab.indicesBadgeLabel": "{numIndices, plural, other {インデックス}}", "xpack.upgradeAssistant.checkupTab.indicesTabLabel": "インデックス", @@ -21515,8 +21501,6 @@ "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel": "{strongCheckupLabel} の問題がありません。", "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle": "完璧です!", "xpack.upgradeAssistant.checkupTab.numDeprecationsShownLabel": "{total} 件中 {numShown} 件を表示中", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.apmIndexPatternCallout.apmSetupLinkLabel": "APMのセットアップ手順", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.apmIndexPatternCallout.calloutTitle": "APMインデックスの再インデックス後、{apmSetupLink}に戻ってKibanaオブジェクトを再度読み込んでください。これは1度だけ必要な作業です。", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel": "キャンセル", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.closeButtonLabel": "閉じる", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.continueButtonLabel": "再インデックスを続ける", @@ -21547,10 +21531,6 @@ "xpack.upgradeAssistant.checkupTab.reindexing.flyout.indexClosedCallout.calloutDetails.reindexingTakesLongerEmphasis": "再インデックスには通常よりも時間がかかることがあります", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.indexClosedCallout.calloutTitle": "インデックスが閉じました", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.openAndCloseDocumentation": "ドキュメンテーション", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningDetail": "バージョン 7.0.0 以降、APM データは Elastic Common Schema で表示されます。過去の APM データは再インデックスされるまで表示されません。", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningTitle": "このインデックスは ECS 形式に変換されます", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.customTypeNameWarningDetail": "マッピングタイプは8.xではサポートされていません。このインデックスマッピングはデフォルトのタイプ名、{defaultType}を使用しておらず、再インデックス時に更新されます。アプリケーションコードまたはスクリプトが異なるタイプに依存していないことを確認してください。", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.customTypeNameWarningTitle": "マッピングタイプが{defaultType}に変更されます", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutDetail": "インデックスをバックアップして、互換性を破るそれぞれの変更に同意することで再インデックスしてください。", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutTitle": "このインデックスには元に戻すことのできない破壊的な変更が含まれています", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.documentationLinkLabel": "ドキュメント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1eee5f0d2f4dc..ebfe4890df38e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5960,19 +5960,10 @@ "xpack.canvas.functions.ltHelpText": "返回 {CONTEXT} 是否小于参数。", "xpack.canvas.functions.mapCenter.args.latHelpText": "地图中心的纬度", "xpack.canvas.functions.mapCenterHelpText": "返回包含地图中心坐标和缩放级别的对象。", - "xpack.canvas.functions.mapColumn.args.expressionHelpText": "作为单行 {DATATABLE} 传递到每一行的 {CANVAS} 表达式。", - "xpack.canvas.functions.mapColumn.args.nameHelpText": "结果列的名称。", - "xpack.canvas.functions.mapColumnHelpText": "添加计算为其他列的结果的列。只有提供参数时,才会执行更改。另请参见 {alterColumnFn} 和 {staticColumnFn}。", "xpack.canvas.functions.markdown.args.contentHelpText": "包含 {MARKDOWN} 的文本字符串。要进行串联,请多次传递 {stringFn} 函数。", "xpack.canvas.functions.markdown.args.fontHelpText": "内容的 {CSS} 字体属性。例如 {fontFamily} 或 {fontWeight}。", "xpack.canvas.functions.markdown.args.openLinkHelpText": "用于在新标签页中打开链接的 true 或 false 值。默认值为 `false`。设置为 `true` 时将在新标签页中打开所有链接。", "xpack.canvas.functions.markdownHelpText": "添加呈现 {MARKDOWN} 文本的元素。提示:将 {markdownFn} 函数用于单个数字、指标和文本段落。", - "xpack.canvas.functions.math.args.expressionHelpText": "已计算的 {TINYMATH} 表达式。请参阅 {TINYMATH_URL}。", - "xpack.canvas.functions.math.emptyDatatableErrorMessage": "空数据表", - "xpack.canvas.functions.math.emptyExpressionErrorMessage": "空表达式", - "xpack.canvas.functions.math.executionFailedErrorMessage": "无法执行数学表达式。检查您的列名称", - "xpack.canvas.functions.math.tooManyResultsErrorMessage": "表达式必须返回单个数字。尝试将您的表达式包装在 {mean} 或 {sum} 中", - "xpack.canvas.functions.mathHelpText": "使用 {TYPE_NUMBER} 或 {DATATABLE} 作为 {CONTEXT} 来解释 {TINYMATH} 数学表达式。{DATATABLE} 列按列名使用。如果 {CONTEXT} 是数字,则作为 {value} 使用。", "xpack.canvas.functions.metric.args.labelFontHelpText": "标签的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", "xpack.canvas.functions.metric.args.labelHelpText": "描述指标的文本。", "xpack.canvas.functions.metric.args.metricFontHelpText": "指标的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", @@ -9499,7 +9490,6 @@ "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotFieldTitle": "可搜索快照", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutBody": "要创建可搜索快照,需要企业许可证。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutTitle": "需要企业许可证", - "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotNoRolloverCalloutBody": "在热阶段禁用滚动更新后,无法创建可搜索快照。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotRepoRequiredError": "快照存储库名称必填。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotsToggleLabel": "创建可搜索快照", "xpack.indexLifecycleMgmt.editPolicy.showPolicyJsonButto": "显示请求", @@ -21552,10 +21542,6 @@ "xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel": "索引", "xpack.upgradeAssistant.checkupTab.deprecations.warningActionTooltip": "建议在升级之前先解决此问题,但这不是必需的。", "xpack.upgradeAssistant.checkupTab.deprecations.warningLabel": "警告", - "xpack.upgradeAssistant.checkupTab.fixMetricbeatIndexButton.failedLabel": "失败", - "xpack.upgradeAssistant.checkupTab.fixMetricbeatIndexButton.fixedLabel": "已修复", - "xpack.upgradeAssistant.checkupTab.fixMetricbeatIndexButton.fixingLabel": "正在修复……", - "xpack.upgradeAssistant.checkupTab.fixMetricbeatIndexButton.reindexLabel": "修复", "xpack.upgradeAssistant.checkupTab.indexLabel": "索引", "xpack.upgradeAssistant.checkupTab.indicesBadgeLabel": "{numIndices, plural, other { 个索引}}", "xpack.upgradeAssistant.checkupTab.indicesTabLabel": "索引", @@ -21565,8 +21551,6 @@ "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel": "您没有 {strongCheckupLabel} 问题。", "xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle": "全部清除!", "xpack.upgradeAssistant.checkupTab.numDeprecationsShownLabel": "显示 {numShown} 个,共 {total} 个", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.apmIndexPatternCallout.apmSetupLinkLabel": "APM 设置说明", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.apmIndexPatternCallout.calloutTitle": "重新索引 APM 索引后,返回 {apmSetupLink} 以重新加载 Kibana 对象。您只需执行一次此操作。", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel": "取消", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.closeButtonLabel": "关闭", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.continueButtonLabel": "继续重新索引", @@ -21597,10 +21581,6 @@ "xpack.upgradeAssistant.checkupTab.reindexing.flyout.indexClosedCallout.calloutDetails.reindexingTakesLongerEmphasis": "重新索引可能比通常花费更多的时间", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.indexClosedCallout.calloutTitle": "索引已关闭", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.openAndCloseDocumentation": "文档", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningDetail": "从版本 7.0.0 开始,将以 Elastic Common Schema 格式表示 APM 数据。只有重新索引历史 APM 数据后,其才可见。", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningTitle": "此索引将转换成 ECS 格式", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.customTypeNameWarningDetail": "映射类型在 8.x 中不再受支持。此索引映射不使用默认类型名称 {defaultType},并将在重新索引时更新。确保没有应用程序代码或脚本依赖其他类型。", - "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.customTypeNameWarningTitle": "映射类型将更改为 {defaultType}", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutDetail": "备份您的索引,然后通过接受每个重大更改来继续重新索引。", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutTitle": "此索引需要无法撤消的破坏性更改", "xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.documentationLinkLabel": "文档", diff --git a/x-pack/plugins/upgrade_assistant/common/constants.ts b/x-pack/plugins/upgrade_assistant/common/constants.ts new file mode 100644 index 0000000000000..d9a056cebcc21 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/common/constants.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import SemVer from 'semver/classes/semver'; + +/* + * These constants are used only in tests to add conditional logic based on Kibana version + * On master, the version should represent the next major version (e.g., master --> 8.0.0) + * The release branch should match the release version (e.g., 7.x --> 7.0.0) + */ +export const mockKibanaVersion = '7.0.0'; +export const mockKibanaSemverVersion = new SemVer(mockKibanaVersion); diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index 3be01fdb8f7dc..adf0661feb66b 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -94,8 +94,9 @@ export type ReindexSavedObject = SavedObject; export enum ReindexWarning { // 7.0 -> 8.0 warnings - apmReindex, customTypeName, + + // 8.0 -> 9.0 warnings } export enum IndexGroup { diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index 0a3da4c055b1c..c4c6f23611f2b 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -4,6 +4,6 @@ "server": true, "ui": true, "configPath": ["xpack", "upgrade_assistant"], - "requiredPlugins": ["management", "licensing", "apmOss", "features"], + "requiredPlugins": ["management", "licensing", "features"], "optionalPlugins": ["cloud", "usageCollection"] } diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx index 8810569027cc8..b732f6806a388 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx @@ -6,9 +6,9 @@ */ import React from 'react'; -import SemVer from 'semver/classes/semver'; import { mountWithIntl } from '@kbn/test/jest'; import { httpServiceMock } from 'src/core/public/mocks'; +import { mockKibanaSemverVersion } from '../../../common/constants'; import { UpgradeAssistantTabs } from './tabs'; import { LoadingState } from './types'; @@ -18,7 +18,6 @@ import { OverviewTab } from './tabs/overview'; const promisesToResolve = () => new Promise((resolve) => setTimeout(resolve, 0)); const mockHttp = httpServiceMock.createSetupContract(); -const mockKibanaVersion = new SemVer('7.0.0'); jest.mock('../app_context', () => { return { @@ -29,9 +28,9 @@ jest.mock('../app_context', () => { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', }, kibanaVersionInfo: { - currentMajor: mockKibanaVersion.major, - prevMajor: mockKibanaVersion.major - 1, - nextMajor: mockKibanaVersion.major + 1, + currentMajor: mockKibanaSemverVersion.major, + prevMajor: mockKibanaSemverVersion.major - 1, + nextMajor: mockKibanaSemverVersion.major + 1, }, }; }, diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx index feffd4f7853cd..bf890c856239e 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import SemVer from 'semver/classes/semver'; +import { mockKibanaSemverVersion } from '../../../../../common/constants'; import { LoadingState } from '../../types'; import AssistanceData from '../__fixtures__/checkup_api_response.json'; @@ -22,8 +22,6 @@ const defaultProps = { setSelectedTabIndex: jest.fn(), }; -const mockKibanaVersion = new SemVer('7.0.0'); - jest.mock('../../../app_context', () => { return { useAppContext: () => { @@ -33,9 +31,9 @@ jest.mock('../../../app_context', () => { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', }, kibanaVersionInfo: { - currentMajor: mockKibanaVersion.major, - prevMajor: mockKibanaVersion.major - 1, - nextMajor: mockKibanaVersion.major + 1, + currentMajor: mockKibanaSemverVersion.major, + prevMajor: mockKibanaSemverVersion.major - 1, + nextMajor: mockKibanaSemverVersion.major + 1, }, }; }, diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/cell.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/cell.tsx index dd69263b1c856..d7487c2744b2b 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/cell.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/cell.tsx @@ -17,7 +17,6 @@ import { EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { FixDefaultFieldsButton } from './default_fields/button'; import { ReindexButton } from './reindex'; import { AppContext } from '../../../../app_context'; import { EnrichedDeprecationInfo } from '../../../../../../common/types'; @@ -99,14 +98,6 @@ export const DeprecationCell: FunctionComponent = ({ )} - - {needsDefaultFields && ( - - - {({ http }) => } - - - )} diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/default_fields/button.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/default_fields/button.tsx deleted file mode 100644 index 327051c6e6092..0000000000000 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/default_fields/button.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { ReactNode } from 'react'; -import { HttpSetup } from 'src/core/public'; - -import { EuiButton } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { LoadingState } from '../../../../types'; - -/** - * Field types used by Metricbeat to generate the default_field setting. - * Matches Beats code here: - * https://github.com/elastic/beats/blob/eee127cb59b56f2ed7c7e317398c3f79c4158216/libbeat/template/processor.go#L104 - */ -const BEAT_DEFAULT_FIELD_TYPES: ReadonlySet = new Set(['keyword', 'text', 'ip']); -const BEAT_OTHER_DEFAULT_FIELDS: ReadonlySet = new Set(['fields.*']); - -interface FixDefaultFieldsButtonProps { - http: HttpSetup; - indexName: string; -} - -interface FixDefaultFieldsButtonState { - fixLoadingState?: LoadingState; -} - -/** - * Renders a button if given index is a valid Metricbeat index to add a default_field setting. - */ -export class FixDefaultFieldsButton extends React.Component< - FixDefaultFieldsButtonProps, - FixDefaultFieldsButtonState -> { - constructor(props: FixDefaultFieldsButtonProps) { - super(props); - this.state = {}; - } - - public render() { - const { fixLoadingState } = this.state; - - if (!this.isBeatsIndex()) { - return null; - } - - const buttonProps: any = { size: 's', onClick: this.fixBeatsIndex }; - let buttonContent: ReactNode; - - switch (fixLoadingState) { - case LoadingState.Loading: - buttonProps.disabled = true; - buttonProps.isLoading = true; - buttonContent = ( - - ); - break; - case LoadingState.Success: - buttonProps.iconSide = 'left'; - buttonProps.iconType = 'check'; - buttonProps.disabled = true; - buttonContent = ( - - ); - break; - case LoadingState.Error: - buttonProps.color = 'danger'; - buttonProps.iconSide = 'left'; - buttonProps.iconType = 'cross'; - buttonContent = ( - - ); - break; - default: - buttonContent = ( - - ); - } - - return {buttonContent}; - } - - private isBeatsIndex = () => { - const { indexName } = this.props; - return indexName.startsWith('metricbeat-') || indexName.startsWith('filebeat-'); - }; - - private fixBeatsIndex = async () => { - if (!this.isBeatsIndex()) { - return; - } - - this.setState({ - fixLoadingState: LoadingState.Loading, - }); - - try { - await this.props.http.post( - `/api/upgrade_assistant/add_query_default_field/${this.props.indexName}`, - { - body: JSON.stringify({ - fieldTypes: [...BEAT_DEFAULT_FIELD_TYPES], - otherFields: [...BEAT_OTHER_DEFAULT_FIELDS], - }), - } - ); - - this.setState({ - fixLoadingState: LoadingState.Success, - }); - } catch (e) { - this.setState({ - fixLoadingState: LoadingState.Error, - }); - } - }; -} diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx index d7925655b556b..79c31ba0b6bb1 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx @@ -10,7 +10,6 @@ import React from 'react'; import { EuiBasicTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FixDefaultFieldsButton } from './default_fields/button'; import { ReindexButton } from './reindex'; import { AppContext } from '../../../../app_context'; import { EnrichedDeprecationInfo } from '../../../../../../common/types'; @@ -145,11 +144,11 @@ export class IndexDeprecationTable extends React.Component< private generateActionsColumn() { // NOTE: this naive implementation assumes all indices in the table are - // should show the reindex button. This should work for known usecases. + // should show the reindex button. This should work for known use cases. const { indices } = this.props; - const showReindexButton = indices.find((i) => i.reindex === true); - const showNeedsDefaultFieldsButton = indices.find((i) => i.needsDefaultFields === true); - if (!showReindexButton && !showNeedsDefaultFieldsButton) { + const hasActionsColumn = Boolean(indices.find((i) => i.reindex === true)); + + if (hasActionsColumn === false) { return null; } @@ -157,28 +156,20 @@ export class IndexDeprecationTable extends React.Component< actions: [ { render(indexDep: IndexDeprecationDetails) { - if (showReindexButton) { - return ( - - {({ http, docLinks }) => { - return ( - - ); - }} - - ); - } else { - return ( - - {({ http }) => } - - ); - } + return ( + + {({ http, docLinks }) => { + return ( + + ); + }} + + ); }, }, ], diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap index 3e40d15906def..3ab877c573ef7 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap @@ -57,30 +57,6 @@ exports[`ChecklistFlyout renders 1`] = ` } } /> - - - - , - } - } - /> - } - /> } onChange={[Function]} - warning={1} - /> - - } - documentationUrl="https://www.elastic.co/guide/en/observability/master/whats-new.html" - label={ - - } - onChange={[Function]} warning={0} /> diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.test.tsx index 760168c7f9457..bde9aac7b2bf6 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.test.tsx @@ -34,7 +34,7 @@ describe('ChecklistFlyout', () => { status: undefined, reindexTaskPercComplete: null, errorMessage: null, - reindexWarnings: [ReindexWarning.apmReindex], + reindexWarnings: [ReindexWarning.customTypeName], hasRequiredPrivileges: true, } as ReindexState, }; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.tsx index 6ea97479fbf6d..e630e25abc0fb 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/checklist_step.tsx @@ -16,13 +16,12 @@ import { EuiFlexItem, EuiFlyoutBody, EuiFlyoutFooter, - EuiLink, EuiSpacer, EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ReindexStatus, ReindexWarning } from '../../../../../../../../common/types'; +import { ReindexStatus } from '../../../../../../../../common/types'; import { LoadingState } from '../../../../../types'; import { ReindexState } from '../polling_service'; import { ReindexProgress } from './progress'; @@ -78,7 +77,7 @@ export const ChecklistFlyoutStep: React.FunctionComponent<{ cancelReindex: () => void; http: HttpSetup; }> = ({ closeFlyout, reindexState, startReindex, cancelReindex, http, renderGlobalCallouts }) => { - const { loadingState, status, hasRequiredPrivileges, reindexWarnings } = reindexState; + const { loadingState, status, hasRequiredPrivileges } = reindexState; const loading = loadingState === LoadingState.Loading || status === ReindexStatus.inProgress; return ( @@ -135,34 +134,6 @@ export const ChecklistFlyoutStep: React.FunctionComponent<{ - {reindexWarnings && reindexWarnings.includes(ReindexWarning.apmReindex) && ( - - - - - - ), - }} - /> - } - color="warning" - iconType="alert" - /> - - )} diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx index 0ebf42df53188..d365cd82ba86c 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx @@ -8,6 +8,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { mount, shallow } from 'enzyme'; import React from 'react'; +import { mockKibanaSemverVersion } from '../../../../../../../../common/constants'; import { ReindexWarning } from '../../../../../../../../common/types'; import { idForWarning, WarningsFlyoutStep } from './warnings_step'; @@ -20,6 +21,11 @@ jest.mock('../../../../../../app_context', () => { DOC_LINK_VERSION: 'current', ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', }, + kibanaVersionInfo: { + currentMajor: mockKibanaSemverVersion.major, + prevMajor: mockKibanaSemverVersion.major - 1, + nextMajor: mockKibanaSemverVersion.major + 1, + }, }; }, }; @@ -28,7 +34,7 @@ jest.mock('../../../../../../app_context', () => { describe('WarningsFlyoutStep', () => { const defaultProps = { advanceNextStep: jest.fn(), - warnings: [ReindexWarning.customTypeName, ReindexWarning.apmReindex], + warnings: [ReindexWarning.customTypeName], closeFlyout: jest.fn(), renderGlobalCallouts: jest.fn(), }; @@ -37,23 +43,21 @@ describe('WarningsFlyoutStep', () => { expect(shallow()).toMatchSnapshot(); }); - it('does not allow proceeding until all are checked', () => { - const wrapper = mount( - - - - ); - const button = wrapper.find('EuiButton'); - - button.simulate('click'); - expect(defaultProps.advanceNextStep).not.toHaveBeenCalled(); - - wrapper.find(`input#${idForWarning(ReindexWarning.apmReindex)}`).simulate('change'); - button.simulate('click'); - expect(defaultProps.advanceNextStep).not.toHaveBeenCalled(); - - wrapper.find(`input#${idForWarning(ReindexWarning.customTypeName)}`).simulate('change'); - button.simulate('click'); - expect(defaultProps.advanceNextStep).toHaveBeenCalled(); - }); + if (mockKibanaSemverVersion.major === 7) { + it('does not allow proceeding until all are checked', () => { + const wrapper = mount( + + + + ); + const button = wrapper.find('EuiButton'); + + button.simulate('click'); + expect(defaultProps.advanceNextStep).not.toHaveBeenCalled(); + + wrapper.find(`input#${idForWarning(ReindexWarning.customTypeName)}`).simulate('change'); + button.simulate('click'); + expect(defaultProps.advanceNextStep).toHaveBeenCalled(); + }); + } }); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx index ebcd97255538c..fe29941f801cc 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx @@ -101,10 +101,9 @@ export const WarningsFlyoutStep: React.FunctionComponent @@ -130,7 +129,7 @@ export const WarningsFlyoutStep: React.FunctionComponent - {warnings.includes(ReindexWarning.customTypeName) && ( + {kibanaVersionInfo.currentMajor === 7 && warnings.includes(ReindexWarning.customTypeName) && ( _doc, }} @@ -158,28 +156,6 @@ export const WarningsFlyoutStep: React.FunctionComponent )} - - {warnings.includes(ReindexWarning.apmReindex) && ( - - } - description={ - - } - documentationUrl={`${observabilityDocBasePath}/master/whats-new.html`} - /> - )} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts index 6caad4f5050fc..d93fe7920f1d7 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/__fixtures__/version.ts @@ -5,16 +5,13 @@ * 2.0. */ -import { SemVer } from 'semver'; +import { mockKibanaSemverVersion } from '../../../common/constants'; -export const MOCK_VERSION_STRING = '8.0.0'; - -export const getMockVersionInfo = (versionString = MOCK_VERSION_STRING) => { - const currentVersion = new SemVer(versionString); - const currentMajor = currentVersion.major; +export const getMockVersionInfo = () => { + const currentMajor = mockKibanaSemverVersion.major; return { - currentVersion, + currentVersion: mockKibanaSemverVersion, currentMajor, prevMajor: currentMajor - 1, nextMajor: currentMajor + 1, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/__snapshots__/es_migration_apis.test.ts.snap b/x-pack/plugins/upgrade_assistant/server/lib/__snapshots__/es_migration_apis.test.ts.snap index 7491fb38b849f..244fc96acd194 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/__snapshots__/es_migration_apis.test.ts.snap +++ b/x-pack/plugins/upgrade_assistant/server/lib/__snapshots__/es_migration_apis.test.ts.snap @@ -29,13 +29,48 @@ Object { }, ], "indices": Array [ + Object { + "blockerForReindexing": undefined, + "details": "[[type: doc, field: spins], [type: doc, field: mlockall], [type: doc, field: node_master], [type: doc, field: primary]]", + "index": ".monitoring-es-6-2018.11.07", + "level": "warning", + "message": "Coercion of boolean fields", + "reindex": false, + "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", + }, Object { "blockerForReindexing": undefined, "details": "[[type: tweet, field: liked]]", "index": "twitter", "level": "warning", "message": "Coercion of boolean fields", - "needsDefaultFields": false, + "reindex": false, + "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", + }, + Object { + "blockerForReindexing": undefined, + "details": "[[type: index-pattern, field: notExpandable], [type: config, field: xPackMonitoring:allowReport], [type: config, field: xPackMonitoring:showBanner], [type: dashboard, field: pause], [type: dashboard, field: timeRestore]]", + "index": ".kibana", + "level": "warning", + "message": "Coercion of boolean fields", + "reindex": false, + "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", + }, + Object { + "blockerForReindexing": undefined, + "details": "[[type: doc, field: notify], [type: doc, field: created], [type: doc, field: attach_payload], [type: doc, field: met]]", + "index": ".watcher-history-6-2018.11.07", + "level": "warning", + "message": "Coercion of boolean fields", + "reindex": false, + "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", + }, + Object { + "blockerForReindexing": undefined, + "details": "[[type: doc, field: snapshot]]", + "index": ".monitoring-kibana-6-2018.11.07", + "level": "warning", + "message": "Coercion of boolean fields", "reindex": false, "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, @@ -45,7 +80,6 @@ Object { "index": "twitter2", "level": "warning", "message": "Coercion of boolean fields", - "needsDefaultFields": false, "reindex": false, "url": "https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_fields", }, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/apm/extract_index_patterns.ts b/x-pack/plugins/upgrade_assistant/server/lib/apm/extract_index_patterns.ts deleted file mode 100644 index 163373bd7f419..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/apm/extract_index_patterns.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { uniq, pick } from 'lodash'; -import { APMOSSConfig } from '../../../../../../src/plugins/apm_oss/server'; - -export const extractIndexPatterns = (apmConfig: APMOSSConfig): string[] => { - const indexConfigs = pick(apmConfig, [ - 'sourcemapIndices', - 'errorIndices', - 'transactionIndices', - 'spanIndices', - 'metricsIndices', - 'onboardingIndices', - ]); - - return uniq(Object.values(indexConfigs)); -}; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/apm/index.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/apm/index.test.ts deleted file mode 100644 index 74d36140175ff..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/apm/index.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { RequestEvent } from '@elastic/elasticsearch/lib/Transport'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ScopedClusterClientMock } from 'src/core/server/elasticsearch/client/mocks'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; -import { getDeprecatedApmIndices, isLegacyApmIndex } from './index'; - -describe('getDeprecatedApmIndices', () => { - let clusterClient: ScopedClusterClientMock; - - const asApiResponse = (body: T): RequestEvent => - ({ - body, - } as RequestEvent); - - beforeEach(() => { - clusterClient = elasticsearchServiceMock.createScopedClusterClient(); - - clusterClient.asCurrentUser.indices.getMapping.mockResolvedValueOnce( - asApiResponse({ - 'foo-1': { - mappings: {}, - }, - 'foo-2': { - mappings: { - _meta: { - version: '6.7.0', - }, - }, - }, - 'foo-3': { - mappings: { - _meta: { - version: '7.0.0', - }, - }, - }, - 'foo-4': { - mappings: { - _meta: { - version: '7.1.0', - }, - }, - }, - }) - ); - }); - - it('calls indices.getMapping', async () => { - await getDeprecatedApmIndices(clusterClient, ['foo-*', 'bar-*']); - - expect(clusterClient.asCurrentUser.indices.getMapping).toHaveBeenCalledWith({ - index: 'foo-*,bar-*', - filter_path: '*.mappings._meta.version,*.mappings.properties.@timestamp', - }); - }); - - it('includes mappings not yet at 7.0.0', async () => { - const deprecations = await getDeprecatedApmIndices(clusterClient, ['foo-*']); - - expect(deprecations).toHaveLength(2); - expect(deprecations[0].index).toEqual('foo-1'); - expect(deprecations[1].index).toEqual('foo-2'); - }); - - it('formats the deprecations', async () => { - // @ts-ignore - const [deprecation, _] = await getDeprecatedApmIndices(clusterClient, ['foo-*']); - - expect(deprecation.level).toEqual('warning'); - expect(deprecation.message).toEqual('APM index requires conversion to 7.x format'); - expect(deprecation.url).toEqual( - 'https://www.elastic.co/guide/en/apm/get-started/master/apm-release-notes.html' - ); - expect(deprecation.details).toEqual('This index was created prior to 7.0'); - expect(deprecation.reindex).toBe(true); - }); -}); - -describe('isLegacyApmIndex', () => { - it('is true when for no version', () => { - expect(isLegacyApmIndex('foo-1', ['foo-*'], {})).toEqual(true); - }); - - it('is true when version is less than 7.0.0', () => { - expect( - isLegacyApmIndex('foo-1', ['foo-*'], { - _meta: { version: '6.7.0' }, - }) - ).toEqual(true); - }); - - it('is false when version is 7.0.0', () => { - expect( - isLegacyApmIndex('foo-1', ['foo-*'], { - _meta: { version: '7.0.0' }, - }) - ).toEqual(false); - }); - - it('is false when version is greater than 7.0.0', () => { - expect( - isLegacyApmIndex('foo-1', ['foo-*'], { - _meta: { version: '7.1.0' }, - }) - ).toEqual(false); - }); - - it('is false when using a version qualifier', () => { - expect( - isLegacyApmIndex('foo-1', ['foo-*'], { - _meta: { version: '7.0.0-rc1' }, - }) - ).toEqual(false); - }); - - it('handles multiple index patterns', () => { - expect( - isLegacyApmIndex('bar-1', ['foo-*', 'bar-*'], { - _meta: { version: '6.7.0' }, - }) - ).toEqual(true); - - expect( - isLegacyApmIndex('bar-1', ['foo-*', 'bar-*'], { - _meta: { version: '7.0.0' }, - }) - ).toEqual(false); - }); -}); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/apm/index.ts b/x-pack/plugins/upgrade_assistant/server/lib/apm/index.ts deleted file mode 100644 index 8c689df78e555..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/apm/index.ts +++ /dev/null @@ -1,493 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import minimatch from 'minimatch'; -import { SemVer, valid } from 'semver'; -import { IScopedClusterClient } from 'src/core/server'; - -import { EnrichedDeprecationInfo } from '../../../common/types'; -import { FlatSettings } from '../reindexing/types'; - -export async function getDeprecatedApmIndices( - clusterClient: IScopedClusterClient, - indexPatterns: string[] = [] -): Promise { - const { body: indices } = await clusterClient.asCurrentUser.indices.getMapping({ - index: indexPatterns.join(','), - // we include @timestamp to prevent filtering mappings without a version - // since @timestamp is expected to always exist - filter_path: '*.mappings._meta.version,*.mappings.properties.@timestamp', - }); - - return Object.keys(indices).reduce((deprecations: EnrichedDeprecationInfo[], index) => { - if (isLegacyApmIndex(index, indexPatterns, indices[index].mappings)) { - deprecations.push({ - level: 'warning', - message: 'APM index requires conversion to 7.x format', - url: 'https://www.elastic.co/guide/en/apm/get-started/master/apm-release-notes.html', - details: 'This index was created prior to 7.0', - reindex: true, - index, - }); - } - - return deprecations; - }, []); -} - -export const isLegacyApmIndex = ( - indexName: string, - apmIndexPatterns: string[] = [], - mappings: FlatSettings['mappings'] -) => { - const defaultValue = '0.0.0'; - const version = get(mappings, '_meta.version', defaultValue); - const clientVersion = new SemVer(valid(version) ? version : defaultValue); - - if (clientVersion.compareMain('7.0.0') > -1) { - return false; - } - - const find = apmIndexPatterns.find((pattern) => { - return minimatch(indexName, pattern); - }); - - return Boolean(find); -}; - -// source: https://github.com/elastic/apm-integration-testing/blob/master/tests/server/test_upgrade.py -export const apmReindexScript = ` - // add ecs version - ctx._source.ecs = ['version': '1.1.0-dev']; - - // set processor.event - if (ctx._source.processor == null) { - // onboarding docs had no processor pre-6.4 - https://github.com/elastic/kibana/issues/52655 - ctx._source.processor = ["event": "onboarding"]; - } - - // beat -> observer - def beat = ctx._source.remove("beat"); - if (beat != null) { - beat.remove("name"); - ctx._source.observer = beat; - ctx._source.observer.type = "apm-server"; - } - - if (! ctx._source.containsKey("observer")) { - ctx._source.observer = new HashMap(); - } - - // observer.major_version - ctx._source.observer.version_major = 7; - - def listening = ctx._source.remove("listening"); - if (listening != null) { - ctx._source.observer.listening = listening; - } - - // remove host[.name] - // clarify if we can simply delete this or it will be set somewhere else in 7.0 - ctx._source.remove("host"); - - // docker.container -> container - def docker = ctx._source.remove("docker"); - if (docker != null && docker.containsKey("container")) { - ctx._source.container = docker.container; - } - - // rip up context - HashMap context = ctx._source.remove("context"); - if (context != null) { - // context.process -> process - def process = context.remove("process"); - if (process != null) { - def args = process.remove("argv"); - if (args != null) { - process.args = args; - } - ctx._source.process = process; - } - - // context.response -> http.response - HashMap resp = context.remove("response"); - if (resp != null) { - if (! ctx._source.containsKey("http")) { - ctx._source.http = new HashMap(); - } - ctx._source.http.response = resp; - } - - // context.request -> http & url - HashMap request = context.remove("request"); - if (request != null) { - if (! ctx._source.containsKey("http")) { - ctx._source.http = new HashMap(); - } - - // context.request.http_version -> http.version - def http_version = request.remove("http_version"); - if (http_version != null) { - ctx._source.http.version = http_version; - } - - if (request.containsKey("headers")) { - // copy user-agent header - def ua; - for (entry in request["headers"].entrySet()) { - if (entry.getKey().toLowerCase() == "user-agent") { - ua = entry.getValue(); - } - } - if (ua != null) { - ctx._source.user_agent = new HashMap(); - // setting original and original.text is not possible in painless - // as original is a keyword in ES template we cannot set it to a HashMap here, - // so the following is the only possible solution: - ctx._source.user_agent.original = ua.substring(0, Integer.min(1024, ua.length())); - } - } - - // context.request.socket -> request.socket - def socket = request.remove("socket"); - if (socket != null) { - def add_socket = false; - def new_socket = new HashMap(); - def remote_address = socket.remove("remote_address"); - if (remote_address != null) { - add_socket = true; - new_socket.remote_address = remote_address; - } - def encrypted = socket.remove("encrypted"); - if (encrypted != null) { - add_socket = true; - new_socket.encrypted = encrypted; - } - if (add_socket) { - request.socket = new_socket; - } - } - - // context.request.url -> url - HashMap url = request.remove("url"); - def fragment = url.remove("hash"); - if (fragment != null) { - url.fragment = fragment; - } - def domain = url.remove("hostname"); - if (domain != null) { - url.domain = domain; - } - def path = url.remove("pathname"); - if (path != null) { - url.path = path; - } - def scheme = url.remove("protocol"); - if (scheme != null) { - def end = scheme.lastIndexOf(":"); - if (end > -1) { - scheme = scheme.substring(0, end); - } - url.scheme = scheme - } - def original = url.remove("raw"); - if (original != null) { - url.original = original; - } - def port = url.remove("port"); - if (port != null) { - try { - int portNum = Integer.parseInt(port); - url.port = portNum; - } catch (Exception e) { - // toss port - } - } - def query = url.remove("search"); - if (query != null) { - url.query = query; - } - ctx._source.url = url; - - // restore what is left of request, under http - - def body = request.remove("body"); - - ctx._source.http.request = request; - ctx._source.http.request.method = ctx._source.http.request.method?.toLowerCase(); - - // context.request.body -> http.request.body.original - if (body != null) { - ctx._source.http.request.body = new HashMap(); - ctx._source.http.request.body.original = body; - } - } - - // bump timestamp.us by span.start.us for spans - // shouldn't @timestamp this already be a Date? - if (ctx._source.processor.event == "span" && context.service.agent.name == "js-base"){ - def ts = ctx._source.get("@timestamp"); - if (ts != null && !ctx._source.containsKey("timestamp") && ctx._source.span.start.containsKey("us")) { - // add span.start to @timestamp for rum documents v1 - ctx._source.timestamp = new HashMap(); - def tsms = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").parse(ts).getTime(); - ctx._source['@timestamp'] = Instant.ofEpochMilli(tsms + (ctx._source.span.start.us/1000)); - ctx._source.timestamp.us = (tsms*1000) + ctx._source.span.start.us; - } - } - - // context.service.agent -> agent - HashMap service = context.remove("service"); - ctx._source.agent = service.remove("agent"); - - // context.service -> service - ctx._source.service = service; - - // context.system -> host - def system = context.remove("system"); - if (system != null) { - system.os = new HashMap(); - system.os.platform = system.remove("platform"); - ctx._source.host = system; - } - - // context.tags -> labels - def tags = context.remove("tags"); - if (tags != null) { - ctx._source.labels = tags; - } - - // context.user -> user & user_agent - if (context.containsKey("user")) { - HashMap user = context.remove("user"); - // user.username -> user.name - def username = user.remove("username"); - if (username != null) { - user.name = username; - } - - // context.user.ip -> client.ip - def userip = user.remove("ip"); - if (userip != null) { - ctx._source.client = new HashMap(); - ctx._source.client.ip = userip; - } - - // move user-agent info - // this will overwrite the value from http.request.headers if set - def ua = user.remove("user-agent"); - if (ua != null) { - ctx._source.user_agent = new HashMap(); - // setting original and original.text is not possible in painless - // as original is a keyword in ES template we cannot set it to a HashMap here, - // so the following is the only possible solution: - ctx._source.user_agent.original = ua.substring(0, Integer.min(1024, ua.length())); - } - - def pua = user.remove("user_agent"); - if (pua != null) { - if (ctx._source.user_agent == null){ - ctx._source.user_agent = new HashMap(); - } - def os = pua.remove("os"); - def osminor = pua.remove("os_minor"); - def osmajor = pua.remove("os_major"); - def osname = pua.remove("os_name"); - - def newos = new HashMap(); - if (os != null){ - newos.full = os; - } - if (osmajor != null || osminor != null){ - newos.version = osmajor + "." + osminor; - } - if (osname != null){ - newos.name = osname; - } - ctx._source.user_agent.os = newos; - - def device = pua.remove("device"); - if (device != null){ - ctx._source.user_agent.device = new HashMap(); - ctx._source.user_agent.device.name = device; - } - // not exactly reflecting 7.0, but the closes we can get - def major = pua.remove("major"); - if (major != null){ - def version = major; - def minor = pua.remove("minor"); - if (minor != null){ - version += "." + minor; - def patch = pua.remove("patch"); - if (patch != null){ - version += "." + patch - } - } - ctx._source.user_agent.version = version; - } - } - - // remove unknown fields from user, like is_authenticated - def add_user = false; - def new_user = new HashMap(); - def email = user.remove("email"); - if (email != null) { - add_user = true; - new_user.email = email; - } - def id = user.remove("id"); - if (id != null) { - add_user = true; - new_user.id = String.valueOf(id); - } - def name = user.remove("name"); - if (name != null) { - add_user = true; - new_user.name = name; - } - if (add_user) { - ctx._source.user = new_user; - } - } - - // context.custom -> error,transaction,span.custom - def custom = context.remove("custom"); - if (custom != null) { - if (ctx._source.processor.event == "span") { - ctx._source.span.custom = custom; - } else if (ctx._source.processor.event == "transaction") { - ctx._source.transaction.custom = custom; - } else if (ctx._source.processor.event == "error") { - ctx._source.error.custom = custom; - } - } - - // context.page -> error.page,transaction.page - def page = context.remove("page"); - if (page != null) { - if (ctx._source.processor.event == "transaction") { - ctx._source.transaction.page = page; - } else if (ctx._source.processor.event == "error") { - ctx._source.error.page = page; - } - } - - // context.db -> span.db - def db = context.remove("db"); - if (db != null) { - def db_user = db.remove("user"); - if (db_user != null) { - db.user = ["name": db_user]; - } - ctx._source.span.db = db; - } - - // context.http -> span.http - def http = context.remove("http"); - if (http != null) { - // context.http.url -> span.http.url.original - def url = http.remove("url"); - if (url != null) { - http.url = ["original": url]; - } - // context.http.status_code -> span.http.response.status_code - def status_code = http.remove("status_code"); - if (status_code != null) { - http.response = ["status_code": status_code]; - } - // lowercase span.http.method - if (http.containsKey("method")) { - http.method = http.method.toLowerCase(); - } - ctx._source.span.http = http; - } - } - - // per https://github.com/elastic/apm/issues/21 - // if kubernetes.node.name is set, copy it to host.hostname - // else if other kubernetes.* is set, remove host.hostname - // else leave it alone - // relies on system.hostname -> host.hostname already happening earlier in this script - if (ctx._source.kubernetes?.node?.name != null) { - if (! ctx._source.containsKey("host")) { - ctx._source.host = new HashMap(); - } - ctx._source.host.hostname = ctx._source.kubernetes.node.name; - } else if (ctx._source.containsKey("kubernetes")) { - if (ctx._source.host?.hostname != null) { - ctx._source.host.remove("hostname"); - } - } - - if (ctx._source.processor.event == "span") { - def hex_id = ctx._source.span.remove("hex_id"); - def span_id = ctx._source.span.remove("id"); - if (hex_id != null) { - ctx._source.span.id = hex_id; - } else if (span_id != null){ - ctx._source.span.id = span_id.toString(); - } - def parent = ctx._source.span.remove("parent"); - def tr_id = ctx._source.transaction.get("id"); - if (ctx._source.parent == null) { - if (parent != null) { - ctx._source.parent = ["id": parent.toString()]; - } else if (tr_id != null) { - // 7.x UI requires a value for parent.id - https://github.com/elastic/kibana/issues/52763 - ctx._source.parent = ["id": tr_id]; - } - } - } - - // create trace.id - if (ctx._source.processor.event == "transaction" || ctx._source.processor.event == "span" || ctx._source.processor.event == "error") { - if (ctx._source.containsKey("transaction")) { - def tr_id = ctx._source.transaction.get("id"); - if (ctx._source.trace == null && tr_id != null) { - // create a trace id from the transaction.id - // v1 transaction.id was a UUID, should have 122 random bits or so - ctx._source.trace = new HashMap(); - ctx._source.trace.id = tr_id.replace("-", ""); - } - } - - // create timestamp.us from @timestamp - def ts = ctx._source.get("@timestamp"); - if (ts != null && !ctx._source.containsKey("timestamp")) { - //set timestamp.microseconds to @timestamp - ctx._source.timestamp = new HashMap(); - ctx._source.timestamp.us = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").parse(ts).getTime()*1000; - } - - } - - // transaction.span_count.dropped.total -> transaction.span_count.dropped - if (ctx._source.processor.event == "transaction") { - // transaction.span_count.dropped.total -> transaction.span_count.dropped - if (ctx._source.transaction.containsKey("span_count")) { - def dropped = ctx._source.transaction.span_count.remove("dropped"); - if (dropped != null) { - ctx._source.transaction.span_count.dropped = dropped.total; - } - } - } - - if (ctx._source.processor.event == "error") { - // culprit is now a keyword, so trim it down to 1024 chars - def culprit = ctx._source.error.remove("culprit"); - if (culprit != null) { - ctx._source.error.culprit = culprit.substring(0, Integer.min(1024, culprit.length())); - } - - // error.exception is now a list (exception chain) - def exception = ctx._source.error.remove("exception"); - if (exception != null) { - ctx._source.error.exception = [exception]; - } - } -`; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/apm/mapping.json b/x-pack/plugins/upgrade_assistant/server/lib/apm/mapping.json deleted file mode 100644 index f518b824f1108..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/apm/mapping.json +++ /dev/null @@ -1,1598 +0,0 @@ -{ - "_meta": { - "beat": "apm", - "version": "7.0.0" - }, - "date_detection": false, - "dynamic_templates": [ - { - "container.labels": { - "mapping": { - "type": "keyword" - }, - "match_mapping_type": "string", - "path_match": "container.labels.*" - } - }, - { - "fields": { - "mapping": { - "type": "keyword" - }, - "match_mapping_type": "string", - "path_match": "fields.*" - } - }, - { - "docker.container.labels": { - "mapping": { - "type": "keyword" - }, - "match_mapping_type": "string", - "path_match": "docker.container.labels.*" - } - }, - { - "labels": { - "mapping": { - "type": "keyword" - }, - "match_mapping_type": "string", - "path_match": "labels.*" - } - }, - { - "labels": { - "mapping": { - "type": "boolean" - }, - "match_mapping_type": "boolean", - "path_match": "labels.*" - } - }, - { - "labels": { - "mapping": { - "scaling_factor": 1000000, - "type": "scaled_float" - }, - "match_mapping_type": "*", - "path_match": "labels.*" - } - }, - { - "transaction.marks": { - "mapping": { - "type": "keyword" - }, - "match_mapping_type": "string", - "path_match": "transaction.marks.*" - } - }, - { - "transaction.marks.*.*": { - "mapping": { - "scaling_factor": 1000000, - "type": "scaled_float" - }, - "match_mapping_type": "*", - "path_match": "transaction.marks.*.*" - } - }, - { - "strings_as_keyword": { - "mapping": { - "ignore_above": 1024, - "type": "keyword" - }, - "match_mapping_type": "string" - } - } - ], - "properties": { - "@timestamp": { - "type": "date" - }, - "agent": { - "dynamic": false, - "properties": { - "ephemeral_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "hostname": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "client": { - "dynamic": false, - "properties": { - "address": { - "ignore_above": 1024, - "type": "keyword" - }, - "bytes": { - "type": "long" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "packets": { - "type": "long" - }, - "port": { - "type": "long" - } - } - }, - "cloud": { - "properties": { - "account": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "availability_zone": { - "ignore_above": 1024, - "type": "keyword" - }, - "instance": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "machine": { - "properties": { - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "project": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "provider": { - "ignore_above": 1024, - "type": "keyword" - }, - "region": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "container": { - "dynamic": false, - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "image": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "tag": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "labels": { - "type": "object" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "runtime": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "destination": { - "properties": { - "address": { - "ignore_above": 1024, - "type": "keyword" - }, - "bytes": { - "type": "long" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "packets": { - "type": "long" - }, - "port": { - "type": "long" - } - } - }, - "docker": { - "properties": { - "container": { - "properties": { - "labels": { - "type": "object" - } - } - } - } - }, - "ecs": { - "properties": { - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "error": { - "dynamic": false, - "properties": { - "code": { - "ignore_above": 1024, - "type": "keyword" - }, - "culprit": { - "ignore_above": 1024, - "type": "keyword" - }, - "exception": { - "properties": { - "code": { - "ignore_above": 1024, - "type": "keyword" - }, - "handled": { - "type": "boolean" - }, - "message": { - "norms": false, - "type": "text" - }, - "module": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "grouping_key": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "log": { - "properties": { - "level": { - "ignore_above": 1024, - "type": "keyword" - }, - "logger_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "message": { - "norms": false, - "type": "text" - }, - "param_message": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "message": { - "norms": false, - "type": "text" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "event": { - "properties": { - "action": { - "ignore_above": 1024, - "type": "keyword" - }, - "category": { - "ignore_above": 1024, - "type": "keyword" - }, - "created": { - "type": "date" - }, - "dataset": { - "ignore_above": 1024, - "type": "keyword" - }, - "duration": { - "type": "long" - }, - "end": { - "type": "date" - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "kind": { - "ignore_above": 1024, - "type": "keyword" - }, - "module": { - "ignore_above": 1024, - "type": "keyword" - }, - "original": { - "doc_values": false, - "ignore_above": 1024, - "index": false, - "type": "keyword" - }, - "outcome": { - "ignore_above": 1024, - "type": "keyword" - }, - "risk_score": { - "type": "float" - }, - "risk_score_norm": { - "type": "float" - }, - "severity": { - "type": "long" - }, - "start": { - "type": "date" - }, - "timezone": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "fields": { - "type": "object" - }, - "file": { - "properties": { - "ctime": { - "type": "date" - }, - "device": { - "ignore_above": 1024, - "type": "keyword" - }, - "extension": { - "ignore_above": 1024, - "type": "keyword" - }, - "gid": { - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "ignore_above": 1024, - "type": "keyword" - }, - "inode": { - "ignore_above": 1024, - "type": "keyword" - }, - "mode": { - "ignore_above": 1024, - "type": "keyword" - }, - "mtime": { - "type": "date" - }, - "owner": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "size": { - "type": "long" - }, - "target_path": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "uid": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "group": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "host": { - "dynamic": false, - "properties": { - "architecture": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hostname": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "os": { - "properties": { - "family": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "ignore_above": 1024, - "type": "keyword" - }, - "kernel": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "platform": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "http": { - "dynamic": false, - "properties": { - "request": { - "properties": { - "body": { - "properties": { - "bytes": { - "type": "long" - }, - "content": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "bytes": { - "type": "long" - }, - "method": { - "ignore_above": 1024, - "type": "keyword" - }, - "referrer": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "response": { - "properties": { - "body": { - "properties": { - "bytes": { - "type": "long" - }, - "content": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "bytes": { - "type": "long" - }, - "finished": { - "type": "boolean" - }, - "status_code": { - "type": "long" - } - } - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "kubernetes": { - "dynamic": false, - "properties": { - "annotations": { - "type": "object" - }, - "container": { - "properties": { - "image": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "labels": { - "type": "object" - }, - "namespace": { - "ignore_above": 1024, - "type": "keyword" - }, - "node": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "pod": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "uid": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "labels": { - "dynamic": true, - "type": "object" - }, - "log": { - "properties": { - "level": { - "ignore_above": 1024, - "type": "keyword" - }, - "original": { - "doc_values": false, - "ignore_above": 1024, - "index": false, - "type": "keyword" - } - } - }, - "message": { - "norms": false, - "type": "text" - }, - "network": { - "properties": { - "application": { - "ignore_above": 1024, - "type": "keyword" - }, - "bytes": { - "type": "long" - }, - "community_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "direction": { - "ignore_above": 1024, - "type": "keyword" - }, - "forwarded_ip": { - "type": "ip" - }, - "iana_number": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "packets": { - "type": "long" - }, - "protocol": { - "ignore_above": 1024, - "type": "keyword" - }, - "transport": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "observer": { - "dynamic": false, - "properties": { - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hostname": { - "ignore_above": 1024, - "type": "keyword" - }, - "ip": { - "type": "ip" - }, - "listening": { - "ignore_above": 1024, - "type": "keyword" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "os": { - "properties": { - "family": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "ignore_above": 1024, - "type": "keyword" - }, - "kernel": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "platform": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "serial_number": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "vendor": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - }, - "version_major": { - "type": "byte" - } - } - }, - "organization": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "os": { - "properties": { - "family": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "ignore_above": 1024, - "type": "keyword" - }, - "kernel": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "platform": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "parent": { - "dynamic": false, - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "process": { - "dynamic": false, - "properties": { - "args": { - "ignore_above": 1024, - "type": "keyword" - }, - "executable": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "pid": { - "type": "long" - }, - "ppid": { - "type": "long" - }, - "start": { - "type": "date" - }, - "thread": { - "properties": { - "id": { - "type": "long" - } - } - }, - "title": { - "ignore_above": 1024, - "type": "keyword" - }, - "working_directory": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "processor": { - "properties": { - "event": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "related": { - "properties": { - "ip": { - "type": "ip" - } - } - }, - "server": { - "properties": { - "address": { - "ignore_above": 1024, - "type": "keyword" - }, - "bytes": { - "type": "long" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "packets": { - "type": "long" - }, - "port": { - "type": "long" - } - } - }, - "service": { - "dynamic": false, - "properties": { - "environment": { - "ignore_above": 1024, - "type": "keyword" - }, - "ephemeral_id": { - "ignore_above": 1024, - "type": "keyword" - }, - "framework": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "language": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "runtime": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "state": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "source": { - "properties": { - "address": { - "ignore_above": 1024, - "type": "keyword" - }, - "bytes": { - "type": "long" - }, - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "geo": { - "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "ip": { - "type": "ip" - }, - "mac": { - "ignore_above": 1024, - "type": "keyword" - }, - "packets": { - "type": "long" - }, - "port": { - "type": "long" - } - } - }, - "sourcemap": { - "dynamic": false, - "properties": { - "bundle_filepath": { - "ignore_above": 1024, - "type": "keyword" - }, - "service": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - } - } - }, - "span": { - "dynamic": false, - "properties": { - "action": { - "ignore_above": 1024, - "type": "keyword" - }, - "duration": { - "properties": { - "us": { - "type": "long" - } - } - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "start": { - "properties": { - "us": { - "type": "long" - } - } - }, - "subtype": { - "ignore_above": 1024, - "type": "keyword" - }, - "sync": { - "type": "boolean" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "system": { - "properties": { - "cpu": { - "properties": { - "total": { - "properties": { - "norm": { - "properties": { - "pct": { - "scaling_factor": 1000, - "type": "scaled_float" - } - } - } - } - } - } - }, - "memory": { - "properties": { - "actual": { - "properties": { - "free": { - "type": "long" - } - } - }, - "total": { - "type": "long" - } - } - }, - "process": { - "properties": { - "cpu": { - "properties": { - "total": { - "properties": { - "norm": { - "properties": { - "pct": { - "scaling_factor": 1000, - "type": "scaled_float" - } - } - } - } - } - } - }, - "memory": { - "properties": { - "rss": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "size": { - "type": "long" - } - } - } - } - } - } - }, - "tags": { - "ignore_above": 1024, - "type": "keyword" - }, - "timestamp": { - "properties": { - "us": { - "type": "long" - } - } - }, - "trace": { - "dynamic": false, - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "transaction": { - "dynamic": false, - "properties": { - "duration": { - "properties": { - "us": { - "type": "long" - } - } - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "marks": { - "dynamic": true, - "properties": { - "*": { - "properties": { - "*": { - "dynamic": true, - "type": "object" - } - } - } - }, - "type": "object" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "result": { - "ignore_above": 1024, - "type": "keyword" - }, - "sampled": { - "type": "boolean" - }, - "span_count": { - "properties": { - "dropped": { - "type": "long" - } - } - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "url": { - "dynamic": false, - "properties": { - "domain": { - "ignore_above": 1024, - "type": "keyword" - }, - "fragment": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "ignore_above": 1024, - "type": "keyword" - }, - "original": { - "ignore_above": 1024, - "type": "keyword" - }, - "password": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "port": { - "type": "long" - }, - "query": { - "ignore_above": 1024, - "type": "keyword" - }, - "scheme": { - "ignore_above": 1024, - "type": "keyword" - }, - "username": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "user": { - "dynamic": false, - "properties": { - "email": { - "ignore_above": 1024, - "type": "keyword" - }, - "full_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "group": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "hash": { - "ignore_above": 1024, - "type": "keyword" - }, - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "user_agent": { - "dynamic": false, - "properties": { - "device": { - "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "original": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "os": { - "properties": { - "family": { - "ignore_above": 1024, - "type": "keyword" - }, - "full": { - "ignore_above": 1024, - "type": "keyword" - }, - "kernel": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "platform": { - "ignore_above": 1024, - "type": "keyword" - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "version": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "view spans": { - "ignore_above": 1024, - "type": "keyword" - } - } -} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts index c14ff5b842fd9..9ab8d0aa7cffb 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts @@ -37,12 +37,12 @@ describe('getUpgradeAssistantStatus', () => { esClient.asCurrentUser.indices.resolveIndex.mockResolvedValue(asApiResponse(resolvedIndices)); it('calls /_migration/deprecations', async () => { - await getUpgradeAssistantStatus(esClient, false, []); + await getUpgradeAssistantStatus(esClient, false); expect(esClient.asCurrentUser.migration.deprecations).toHaveBeenCalled(); }); it('returns the correct shape of data', async () => { - const resp = await getUpgradeAssistantStatus(esClient, false, []); + const resp = await getUpgradeAssistantStatus(esClient, false); expect(resp).toMatchSnapshot(); }); @@ -56,7 +56,7 @@ describe('getUpgradeAssistantStatus', () => { }) ); - await expect(getUpgradeAssistantStatus(esClient, false, [])).resolves.toHaveProperty( + await expect(getUpgradeAssistantStatus(esClient, false)).resolves.toHaveProperty( 'readyForUpgrade', false ); @@ -72,7 +72,7 @@ describe('getUpgradeAssistantStatus', () => { }) ); - await expect(getUpgradeAssistantStatus(esClient, false, [])).resolves.toHaveProperty( + await expect(getUpgradeAssistantStatus(esClient, false)).resolves.toHaveProperty( 'readyForUpgrade', true ); @@ -94,7 +94,7 @@ describe('getUpgradeAssistantStatus', () => { }) ); - const result = await getUpgradeAssistantStatus(esClient, true, []); + const result = await getUpgradeAssistantStatus(esClient, true); expect(result).toHaveProperty('readyForUpgrade', true); expect(result).toHaveProperty('cluster', []); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts index 4985cad8bd750..6e357fd755c82 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts @@ -12,23 +12,18 @@ import { UpgradeAssistantStatus, } from '../../common/types'; -import { getDeprecatedApmIndices } from './apm'; -import { isSystemIndex } from './reindexing'; - import { esIndicesStateCheck } from './es_indices_state_check'; export async function getUpgradeAssistantStatus( dataClient: IScopedClusterClient, - isCloudEnabled: boolean, - apmIndices: string[] + isCloudEnabled: boolean ): Promise { - const [{ body: deprecations }, apmIndexDeprecations] = await Promise.all([ - dataClient.asCurrentUser.migration.deprecations(), - getDeprecatedApmIndices(dataClient, apmIndices), - ]); + const { + body: deprecations, + } = await dataClient.asCurrentUser.migration.deprecations(); const cluster = getClusterDeprecations(deprecations, isCloudEnabled); - const indices = getCombinedIndexInfos(deprecations, apmIndexDeprecations); + const indices = getCombinedIndexInfos(deprecations); const indexNames = indices.map(({ index }) => index!); @@ -53,37 +48,19 @@ export async function getUpgradeAssistantStatus( } // Reformats the index deprecations to an array of deprecation warnings extended with an index field. -const getCombinedIndexInfos = ( - deprecations: DeprecationAPIResponse, - apmIndexDeprecations: EnrichedDeprecationInfo[] -) => { - const apmIndices = apmIndexDeprecations.reduce((acc, dep) => acc.add(dep.index), new Set()); - - return ( - Object.keys(deprecations.index_settings) - // prevent APM indices from showing up for general re-indexing - .filter((indexName) => !apmIndices.has(indexName)) - .reduce((indexDeprecations, indexName) => { - return indexDeprecations.concat( - deprecations.index_settings[indexName].map( - (d) => - ({ - ...d, - index: indexName, - reindex: /Index created before/.test(d.message) && !apmIndices.has(indexName), - needsDefaultFields: /Number of fields exceeds automatic field expansion limit/.test( - d.message - ), - } as EnrichedDeprecationInfo) - ) - ); - }, [] as EnrichedDeprecationInfo[]) - // Filter out warnings for system indices until we know more about what changes are required for the - // next upgrade in a future minor version. Note, we're still including APM depercations below. - .filter((deprecation) => !isSystemIndex(deprecation.index!)) - .concat(apmIndexDeprecations) - ); -}; +const getCombinedIndexInfos = (deprecations: DeprecationAPIResponse) => + Object.keys(deprecations.index_settings).reduce((indexDeprecations, indexName) => { + return indexDeprecations.concat( + deprecations.index_settings[indexName].map( + (d) => + ({ + ...d, + index: indexName, + reindex: /Index created before/.test(d.message), + } as EnrichedDeprecationInfo) + ) + ); + }, [] as EnrichedDeprecationInfo[]); const getClusterDeprecations = (deprecations: DeprecationAPIResponse, isCloudEnabled: boolean) => { const combined = deprecations.cluster_settings diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts index 25dcd2521525d..f4631f3ba459d 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_version_precheck.test.ts @@ -9,7 +9,8 @@ import { SemVer } from 'semver'; import { IScopedClusterClient, kibanaResponseFactory } from 'src/core/server'; import { coreMock } from 'src/core/server/mocks'; import { licensingMock } from '../../../../plugins/licensing/server/mocks'; -import { MOCK_VERSION_STRING, getMockVersionInfo } from './__fixtures__/version'; +import { mockKibanaVersion } from '../../common/constants'; +import { getMockVersionInfo } from './__fixtures__/version'; import { esVersionCheck, @@ -97,7 +98,7 @@ describe('verifyAllMatchKibanaVersion', () => { describe('EsVersionPrecheck', () => { beforeEach(() => { - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); }); it('returns a 403 when callCluster fails with a 403', async () => { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/query_default_field.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/query_default_field.test.ts deleted file mode 100644 index 5edd87b76b2c1..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/query_default_field.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IScopedClusterClient } from 'src/core/server'; -import { RequestEvent } from '@elastic/elasticsearch/lib/Transport'; -import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; -import { MappingProperties } from './reindexing/types'; - -import { addDefaultField, generateDefaultFields } from './query_default_field'; - -const defaultFieldTypes = new Set(['keyword', 'text', 'ip']); - -const asApiResponse = (body: T): RequestEvent => - ({ - body, - } as RequestEvent); - -describe('getDefaultFieldList', () => { - it('returns dot-delimited flat list', () => { - const mapping: MappingProperties = { - nested1: { - properties: { - included2: { type: 'ip' }, - ignored2: { type: 'geopoint' }, - nested2: { - properties: { - included3: { type: 'keyword' }, - 'included4.keyword': { type: 'keyword' }, - }, - }, - }, - }, - ignored1: { type: 'object' }, - included1: { type: 'text' }, - }; - - expect(generateDefaultFields(mapping, defaultFieldTypes)).toMatchInlineSnapshot(` - Array [ - "nested1.included2", - "nested1.nested2.included3", - "nested1.nested2.included4.keyword", - "included1", - ] - `); - }); -}); - -describe('fixMetricbeatIndex', () => { - let dataClient: IScopedClusterClient; - const mockMappings = { - 'metricbeat-1': { - mappings: { properties: { field1: { type: 'text' }, field2: { type: 'float' } } }, - }, - }; - const mockSettings = { - 'metricbeat-1': { - settings: {}, - }, - }; - - beforeEach(() => (dataClient = elasticsearchServiceMock.createScopedClusterClient())); - - it('fails if index already has index.query.default_field setting', async () => { - (dataClient.asCurrentUser.indices.getSettings as jest.Mock).mockResolvedValueOnce( - asApiResponse({ - 'metricbeat-1': { - settings: { index: { query: { default_field: [] } } }, - }, - }) - ); - await expect( - addDefaultField(dataClient, 'metricbeat-1', defaultFieldTypes) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Index metricbeat-1 already has index.query.default_field set"` - ); - }); - - it('updates index settings with default_field generated from mappings and otherFields', async () => { - (dataClient.asCurrentUser.indices.getSettings as jest.Mock).mockResolvedValueOnce( - asApiResponse(mockSettings) - ); - - (dataClient.asCurrentUser.indices.getMapping as jest.Mock).mockResolvedValueOnce( - asApiResponse(mockMappings) - ); - - (dataClient.asCurrentUser.indices.putSettings as jest.Mock).mockResolvedValueOnce( - asApiResponse({ acknowledged: true }) - ); - - await expect( - addDefaultField( - dataClient, - 'metricbeat-1', - defaultFieldTypes, - new Set(['fields.*', 'myCustomField']) - ) - ).resolves.toEqual({ - body: { acknowledged: true }, - }); - expect(dataClient.asCurrentUser.indices.putSettings).toHaveBeenCalledWith({ - body: { - index: { - query: { - default_field: ['field1', 'fields.*', 'myCustomField'], - }, - }, - }, - index: 'metricbeat-1', - }); - }); -}); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/query_default_field.ts b/x-pack/plugins/upgrade_assistant/server/lib/query_default_field.ts deleted file mode 100644 index e625b26232903..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/query_default_field.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IScopedClusterClient } from 'kibana/server'; -import Boom from '@hapi/boom'; -import { get } from 'lodash'; - -import { MappingProperties } from './reindexing/types'; - -/** - * Adds the index.query.default_field setting, generated from the index's mapping. - * - * @param callWithRequest - * @param request - * @param indexName - * @param fieldTypes - Elasticsearch field types that should be used to generate the default_field from the index mapping - * @param otherFields - Other fields that should be included in the generated default_field that do not match `fieldTypes` - */ -export const addDefaultField = async ( - clusterClient: IScopedClusterClient, - indexName: string, - fieldTypes: ReadonlySet, - otherFields: ReadonlySet = new Set() -) => { - // Verify index.query.default_field is not already set. - const { body: settings } = await clusterClient.asCurrentUser.indices.getSettings({ - index: indexName, - }); - if (get(settings, `${indexName}.settings.index.query.default_field`)) { - throw Boom.badRequest(`Index ${indexName} already has index.query.default_field set`); - } - - // Get the mapping and generate the default_field based on `fieldTypes` - const { body: mappingResp } = await clusterClient.asCurrentUser.indices.getMapping({ - index: indexName, - }); - const mapping = mappingResp[indexName].mappings.properties as MappingProperties; - const generatedDefaultFields = new Set(generateDefaultFields(mapping, fieldTypes)); - - // Update the setting with the generated default_field - return clusterClient.asCurrentUser.indices.putSettings({ - index: indexName, - body: { - index: { query: { default_field: [...generatedDefaultFields, ...otherFields] } }, - }, - }); -}; - -/** - * Recursively walks an index mapping and returns a flat array of dot-delimited - * strings represent all fields that are of a type included in `DEFAULT_FIELD_TYPES` - * @param mapping - */ -export const generateDefaultFields = ( - mapping: MappingProperties, - fieldTypes: ReadonlySet -): string[] => - Object.getOwnPropertyNames(mapping).reduce((defaultFields, fieldName) => { - const { type, properties } = mapping[fieldName]; - - if (type && fieldTypes.has(type)) { - defaultFields.push(fieldName); - } else if (properties) { - generateDefaultFields(properties, fieldTypes).forEach((subField) => - defaultFields.push(`${fieldName}.${subField}`) - ); - } - - return defaultFields; - }, [] as string[]); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts index d10242470fabb..9efe0a7fb94b5 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts @@ -13,7 +13,6 @@ import { ReindexTaskFailed, ReindexAlreadyInProgress, MultipleReindexJobsFound, - CannotReindexSystemIndexInCurrent, ReindexCannotBeCancelled, ReindexIsNotInQueue, } from './error_symbols'; @@ -35,7 +34,6 @@ export const error = { reindexTaskFailed: createErrorFactory(ReindexTaskFailed), reindexTaskCannotBeDeleted: createErrorFactory(ReindexTaskCannotBeDeleted), reindexAlreadyInProgress: createErrorFactory(ReindexAlreadyInProgress), - reindexSystemIndex: createErrorFactory(CannotReindexSystemIndexInCurrent), reindexIsNotInQueue: createErrorFactory(ReindexIsNotInQueue), multipleReindexJobsFound: createErrorFactory(MultipleReindexJobsFound), reindexCannotBeCancelled: createErrorFactory(ReindexCannotBeCancelled), diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts index 2a7bfde931989..5bc45dfed4c98 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts @@ -12,7 +12,6 @@ export const CannotCreateIndex = Symbol('CannotCreateIndex'); export const ReindexTaskFailed = Symbol('ReindexTaskFailed'); export const ReindexTaskCannotBeDeleted = Symbol('ReindexTaskCannotBeDeleted'); export const ReindexAlreadyInProgress = Symbol('ReindexAlreadyInProgress'); -export const CannotReindexSystemIndexInCurrent = Symbol('CannotReindexSystemIndexInCurrent'); export const ReindexIsNotInQueue = Symbol('ReindexIsNotInQueue'); export const ReindexCannotBeCancelled = Symbol('ReindexCannotBeCancelled'); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index.ts index 5e53240593213..c1ff38b08678f 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { reindexServiceFactory, isSystemIndex } from './reindex_service'; +export { reindexServiceFactory } from './reindex_service'; export { ReindexWorker } from './worker'; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts index c86f54b0fdda4..f778981b95054 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.test.ts @@ -5,9 +5,10 @@ * 2.0. */ +import { mockKibanaSemverVersion, mockKibanaVersion } from '../../../common/constants'; import { ReindexWarning } from '../../../common/types'; import { versionService } from '../version'; -import { MOCK_VERSION_STRING, getMockVersionInfo } from '../__fixtures__/version'; +import { getMockVersionInfo } from '../__fixtures__/version'; import { generateNewIndexName, @@ -124,7 +125,7 @@ describe('transformFlatSettings', () => { describe('sourceNameForIndex', () => { beforeEach(() => { - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); }); it('parses internal indices', () => { @@ -145,7 +146,7 @@ describe('sourceNameForIndex', () => { describe('generateNewIndexName', () => { beforeEach(() => { - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); }); it('parses internal indices', () => { @@ -179,21 +180,25 @@ describe('getReindexWarnings', () => { ).toEqual([]); }); - it('returns customTypeName for non-_doc mapping types', () => { - expect( - getReindexWarnings({ - settings: {}, - mappings: { doc: {} }, - }) - ).toEqual([ReindexWarning.customTypeName]); - }); - - it('does not return customTypeName for _doc mapping types', () => { - expect( - getReindexWarnings({ - settings: {}, - mappings: { _doc: {} }, - }) - ).toEqual([]); - }); + if (mockKibanaSemverVersion.major === 7) { + describe('customTypeName warning', () => { + it('returns customTypeName for non-_doc mapping types', () => { + expect( + getReindexWarnings({ + settings: {}, + mappings: { doc: {} }, + }) + ).toEqual([ReindexWarning.customTypeName]); + }); + + it('does not return customTypeName for _doc mapping types', () => { + expect( + getReindexWarnings({ + settings: {}, + mappings: { _doc: {} }, + }) + ).toEqual([]); + }); + }); + } }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts index 797f0de4ac95d..d85efb3882643 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/index_settings.ts @@ -7,12 +7,8 @@ import { flow, omit } from 'lodash'; import { ReindexWarning } from '../../../common/types'; -import { isLegacyApmIndex } from '../apm'; import { versionService } from '../version'; import { FlatSettings, FlatSettingsWithTypeName } from './types'; - -export const DEFAULT_TYPE_NAME = '_doc'; - export interface ParsedIndexName { cleanIndexName: string; baseName: string; @@ -73,31 +69,22 @@ export const generateNewIndexName = (indexName: string): string => { * @param flatSettings */ export const getReindexWarnings = ( - flatSettings: FlatSettingsWithTypeName, - apmIndexPatterns: string[] = [] + flatSettings: FlatSettingsWithTypeName | FlatSettings ): ReindexWarning[] => { - const indexName = flatSettings.settings['index.provided_name']; - const typeName = Object.getOwnPropertyNames(flatSettings.mappings)[0]; - const apmReindexWarning = isLegacyApmIndex( - indexName, - apmIndexPatterns, - flatSettings.mappings[typeName] - ); - const typeNameWarning = usesCustomTypeName(flatSettings); - - const warnings = [ - [ReindexWarning.apmReindex, apmReindexWarning], - [ReindexWarning.customTypeName, typeNameWarning], - ] as Array<[ReindexWarning, boolean]>; + const warnings = [] as Array<[ReindexWarning, boolean]>; - return warnings.filter(([_, applies]) => applies).map(([warning, _]) => warning); -}; + if (versionService.getMajorVersion() === 7) { + const DEFAULT_TYPE_NAME = '_doc'; + // In 7+ it's not possible to have more than one type anyways, so always grab the first + // (and only) key. + const typeName = Object.getOwnPropertyNames(flatSettings.mappings)[0]; -const usesCustomTypeName = (flatSettings: FlatSettingsWithTypeName) => { - // In 7+ it's not possible to have more than one type anyways, so always grab the first - // (and only) key. - const typeName = Object.getOwnPropertyNames(flatSettings.mappings)[0]; - return typeName && typeName !== DEFAULT_TYPE_NAME; + const typeNameWarning = Boolean(typeName && typeName !== DEFAULT_TYPE_NAME); + + warnings.push([ReindexWarning.customTypeName, typeNameWarning]); + } + + return warnings.filter(([_, applies]) => applies).map(([warning, _]) => warning); }; const removeUnsettableSettings = (settings: FlatSettings['settings']) => diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts index 59c83a05aa551..592c2d15b9c0c 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts @@ -19,9 +19,10 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; +import { mockKibanaVersion } from '../../../common/constants'; import { versionService } from '../version'; import { LOCK_WINDOW, ReindexActions, reindexActionsFactory } from './reindex_actions'; -import { MOCK_VERSION_STRING, getMockVersionInfo } from '../__fixtures__/version'; +import { getMockVersionInfo } from '../__fixtures__/version'; const { currentMajor, prevMajor } = getMockVersionInfo(); @@ -53,7 +54,7 @@ describe('ReindexActions', () => { describe('createReindexOp', () => { beforeEach(() => { - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); client.create.mockResolvedValue(); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts index c4b15b1453754..8e63367802cd9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts @@ -21,6 +21,7 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; +import { versionService } from '../version'; import { generateNewIndexName } from './index_settings'; import { FlatSettings, FlatSettingsWithTypeName } from './types'; @@ -84,14 +85,12 @@ export interface ReindexActions { /** * Retrieve index settings (in flat, dot-notation style) and mappings. * @param indexName + * @param withTypeName */ - getFlatSettings(indexName: string): Promise; - - /** - * Retrieve index settings (in flat, dot-notation style) and mappings with the type name included. - * @param indexName - */ - getFlatSettingsWithTypeName(indexName: string): Promise; + getFlatSettings( + indexName: string, + withTypeName?: boolean + ): Promise; // ----- Functions below are for enforcing locks around groups of indices like ML or Watcher @@ -242,35 +241,34 @@ export const reindexActionsFactory = ( return allOps; }, - async getFlatSettings(indexName: string) { - const { body: flatSettings } = await esClient.indices.get<{ - [indexName: string]: FlatSettings; - }>({ - index: indexName, - flat_settings: true, - }); - - if (!flatSettings[indexName]) { - return null; + async getFlatSettings(indexName: string, withTypeName?: boolean) { + let flatSettings; + + if (versionService.getMajorVersion() === 7 && withTypeName) { + // On 7.x, we need to get index settings with mapping type + flatSettings = await esClient.indices.get<{ + [indexName: string]: FlatSettingsWithTypeName; + }>({ + index: indexName, + flat_settings: true, + // This @ts-ignore is needed on master since the flag is deprecated on >7.x + // @ts-ignore + include_type_name: true, + }); + } else { + flatSettings = await esClient.indices.get<{ + [indexName: string]: FlatSettings; + }>({ + index: indexName, + flat_settings: true, + }); } - return flatSettings[indexName]; - }, - - async getFlatSettingsWithTypeName(indexName: string) { - const { body: flatSettings } = await esClient.indices.get<{ - [indexName: string]: FlatSettingsWithTypeName; - }>({ - index: indexName, - flat_settings: true, - include_type_name: true, - }); - - if (!flatSettings[indexName]) { + if (!flatSettings.body[indexName]) { return null; } - return flatSettings[indexName]; + return flatSettings.body[indexName]; }, async _fetchAndLockIndexGroupDoc(indexGroup) { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts index 90ff699d79a72..a91cf8ddeada9 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.test.ts @@ -20,12 +20,11 @@ import { ReindexStatus, ReindexStep, } from '../../../common/types'; +import { mockKibanaVersion } from '../../../common/constants'; import { licensingMock } from '../../../../licensing/server/mocks'; import { LicensingPluginSetup } from '../../../../licensing/server'; -import { apmReindexScript } from '../apm'; -import apmMappings from '../apm/mapping.json'; -import { MOCK_VERSION_STRING, getMockVersionInfo } from '../__fixtures__/version'; +import { getMockVersionInfo } from '../__fixtures__/version'; import { esIndicesStateCheck } from '../es_indices_state_check'; import { versionService } from '../version'; @@ -69,7 +68,6 @@ describe('reindexService', () => { findReindexOperations: jest.fn(unimplemented('findReindexOperations')), findAllByStatus: jest.fn(unimplemented('findAllInProgressOperations')), getFlatSettings: jest.fn(unimplemented('getFlatSettings')), - getFlatSettingsWithTypeName: jest.fn(unimplemented('getFlatSettingsWithTypeName')), cleanupChanges: jest.fn(), incrementIndexGroupReindexes: jest.fn(unimplemented('incrementIndexGroupReindexes')), decrementIndexGroupReindexes: jest.fn(unimplemented('decrementIndexGroupReindexes')), @@ -88,11 +86,10 @@ describe('reindexService', () => { clusterClient.asCurrentUser, actions, log, - licensingPluginSetup, - ['apm-*'] + licensingPluginSetup ); - versionService.setup(MOCK_VERSION_STRING); + versionService.setup(mockKibanaVersion); }); describe('hasRequiredPrivileges', () => { @@ -214,7 +211,7 @@ describe('reindexService', () => { describe('detectReindexWarnings', () => { it('fetches reindex warnings from flat settings', async () => { const indexName = 'myIndex'; - actions.getFlatSettingsWithTypeName.mockResolvedValueOnce({ + actions.getFlatSettings.mockResolvedValueOnce({ settings: { 'index.provided_name': indexName, }, @@ -228,7 +225,7 @@ describe('reindexService', () => { }); it('returns null if index does not exist', async () => { - actions.getFlatSettingsWithTypeName.mockResolvedValueOnce(null); + actions.getFlatSettings.mockResolvedValueOnce(null); const reindexWarnings = await service.detectReindexWarnings('myIndex'); expect(reindexWarnings).toBeNull(); }); @@ -251,12 +248,6 @@ describe('reindexService', () => { expect(actions.createReindexOp).not.toHaveBeenCalled(); }); - it('fails if system index', async () => { - actions.getFlatSettings.mockResolvedValueOnce({ settings: {}, mappings: {} }); - await expect(service.createReindexOperation('.myIndex')).rejects.toThrow(); - expect(actions.createReindexOp).not.toHaveBeenCalled(); - }); - it('deletes existing operation if it failed', async () => { clusterClient.asCurrentUser.indices.exists.mockResolvedValueOnce(asApiResponse(true)); actions.findReindexOperations.mockResolvedValueOnce({ @@ -846,46 +837,6 @@ describe('reindexService', () => { }); }); - it('used APM mapping for legacy APM index', async () => { - const indexName = 'apm-1'; - const newIndexName = 'apm-1-reindexed'; - - actions.getFlatSettings.mockResolvedValueOnce({ - settings: { - 'index.number_of_replicas': 5, - }, - mappings: { - _meta: { - version: '6.7.0', - }, - }, - }); - - clusterClient.asCurrentUser.indices.create.mockResolvedValueOnce( - asApiResponse({ acknowledged: true }) - ); - - await service.processNextStep({ - id: '1', - attributes: { - ...defaultAttributes, - indexName, - newIndexName, - lastCompletedStep: ReindexStep.readonly, - }, - } as ReindexSavedObject); - - expect(clusterClient.asCurrentUser.indices.create).toHaveBeenCalledWith({ - index: newIndexName, - body: { - mappings: apmMappings, - settings: { - 'index.number_of_replicas': 5, - }, - }, - }); - }); - it('fails if create index is not acknowledged', async () => { clusterClient.asCurrentUser.indices.get.mockResolvedValueOnce( asApiResponse({ myIndex: settingsMappings }) @@ -958,44 +909,6 @@ describe('reindexService', () => { }); }); - it('uses APM script for legacy APM index', async () => { - const indexName = 'apm-1'; - const newIndexName = 'apm-1-reindexed'; - - clusterClient.asCurrentUser.reindex.mockResolvedValueOnce(asApiResponse({ task: 'xyz' })); - - actions.getFlatSettings.mockResolvedValueOnce({ - settings: {}, - mappings: { - _meta: { - version: '6.7.0', - }, - }, - }); - - await service.processNextStep({ - id: '1', - attributes: { - ...defaultAttributes, - indexName, - newIndexName, - lastCompletedStep: ReindexStep.newIndexCreated, - }, - } as ReindexSavedObject); - expect(clusterClient.asCurrentUser.reindex).toHaveBeenLastCalledWith({ - refresh: true, - wait_for_completion: false, - body: { - source: { index: indexName }, - dest: { index: newIndexName }, - script: { - lang: 'painless', - source: apmReindexScript, - }, - }, - }); - }); - it('fails if starting reindex fails', async () => { clusterClient.asCurrentUser.reindex.mockRejectedValueOnce(new Error('blah!')); const updatedOp = await service.processNextStep(reindexOp); @@ -1057,7 +970,6 @@ describe('reindexService', () => { expect(updatedOp.attributes.reindexTaskPercComplete).toEqual(1); expect(clusterClient.asCurrentUser.delete).toHaveBeenCalledWith({ index: '.tasks', - type: 'task', id: 'xyz', }); }); @@ -1102,7 +1014,6 @@ describe('reindexService', () => { expect(updatedOp.attributes.status).toEqual(ReindexStatus.cancelled); expect(clusterClient.asCurrentUser.delete).toHaveBeenLastCalledWith({ index: '.tasks', - type: 'task', id: 'xyz', }); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index 92eab9be21d95..1b100b70f3750 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -17,13 +17,9 @@ import { ReindexStep, ReindexWarning, } from '../../../common/types'; -import { apmReindexScript, isLegacyApmIndex } from '../apm'; -import apmMappings from '../apm/mapping.json'; import { esIndicesStateCheck } from '../es_indices_state_check'; -import { versionService } from '../version'; - import { generateNewIndexName, getReindexWarnings, @@ -136,8 +132,7 @@ export const reindexServiceFactory = ( esClient: ElasticsearchClient, actions: ReindexActions, log: Logger, - licensing: LicensingPluginSetup, - apmIndexPatterns: string[] = [] + licensing: LicensingPluginSetup ): ReindexService => { // ------ Utility functions @@ -318,13 +313,12 @@ export const reindexServiceFactory = ( } const { settings, mappings } = transformFlatSettings(flatSettings); - const legacyApmIndex = isLegacyApmIndex(indexName, apmIndexPatterns, flatSettings.mappings); const { body: createIndex } = await esClient.indices.create({ index: newIndexName, body: { settings, - mappings: legacyApmIndex ? apmMappings : mappings, + mappings, }, }); @@ -353,28 +347,18 @@ export const reindexServiceFactory = ( await esClient.indices.open({ index: indexName }); } - const reindexBody = { - source: { index: indexName }, - dest: { index: reindexOp.attributes.newIndexName }, - } as any; - const flatSettings = await actions.getFlatSettings(indexName); if (!flatSettings) { throw error.indexNotFound(`Index ${indexName} does not exist.`); } - const legacyApmIndex = isLegacyApmIndex(indexName, apmIndexPatterns, flatSettings.mappings); - if (legacyApmIndex) { - reindexBody.script = { - lang: 'painless', - source: apmReindexScript, - }; - } - const { body: startReindexResponse } = await esClient.reindex({ refresh: true, wait_for_completion: false, - body: reindexBody, + body: { + source: { index: indexName }, + dest: { index: reindexOp.attributes.newIndexName }, + }, }); return actions.updateReindexOp(reindexOp, { @@ -437,7 +421,6 @@ export const reindexServiceFactory = ( // Delete the task from ES .tasks index const { body: deleteTaskResp } = await esClient.delete({ index: '.tasks', - type: 'task', id: taskId, }); @@ -565,11 +548,11 @@ export const reindexServiceFactory = ( }, async detectReindexWarnings(indexName: string) { - const flatSettings = await actions.getFlatSettingsWithTypeName(indexName); + const flatSettings = await actions.getFlatSettings(indexName, true); if (!flatSettings) { return null; } else { - return getReindexWarnings(flatSettings, apmIndexPatterns); + return getReindexWarnings(flatSettings); } }, @@ -582,12 +565,6 @@ export const reindexServiceFactory = ( }, async createReindexOperation(indexName: string, opts?: { enqueue: boolean }) { - if (isSystemIndex(indexName)) { - throw error.reindexSystemIndex( - `Reindexing system indices are not yet supported within this major version. Upgrade to the latest ${versionService.getMajorVersion()}.x minor version.` - ); - } - const { body: indexExists } = await esClient.indices.exists({ index: indexName }); if (!indexExists) { throw error.indexNotFound(`Index ${indexName} does not exist in this cluster.`); @@ -786,8 +763,6 @@ export const reindexServiceFactory = ( }; }; -export const isSystemIndex = (indexName: string) => indexName.startsWith('.'); - export const isMlIndex = (indexName: string) => { const sourceName = sourceNameForIndex(indexName); return ML_INDICES.indexOf(sourceName) >= 0; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts index f96eeec36963b..569316e276e43 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/types.ts @@ -28,6 +28,7 @@ export interface FlatSettings { }; } +// Specific to 7.x-8 upgrade export interface FlatSettingsWithTypeName { settings: { [key: string]: string; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts index 1163f36733287..08b9486e8394a 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -52,8 +52,7 @@ export class ReindexWorker { private credentialStore: CredentialStore, private clusterClient: IClusterClient, log: Logger, - private licensing: LicensingPluginSetup, - private apmIndexPatterns: string[] + private licensing: LicensingPluginSetup ) { this.log = log.get('reindex_worker'); if (ReindexWorker.workerSingleton) { @@ -66,8 +65,7 @@ export class ReindexWorker { callAsInternalUser, reindexActionsFactory(this.client, callAsInternalUser), log, - this.licensing, - apmIndexPatterns + this.licensing ); ReindexWorker.workerSingleton = this; @@ -152,13 +150,7 @@ export class ReindexWorker { const scopedClusterClient = this.clusterClient.asScoped(fakeRequest); const callAsCurrentUser = scopedClusterClient.asCurrentUser; const actions = reindexActionsFactory(this.client, callAsCurrentUser); - return reindexServiceFactory( - callAsCurrentUser, - actions, - this.log, - this.licensing, - this.apmIndexPatterns - ); + return reindexServiceFactory(callAsCurrentUser, actions, this.log, this.licensing); }; private updateInProgressOps = async () => { @@ -234,13 +226,7 @@ export class ReindexWorker { const callAsCurrentUser = scopedClusterClient.asCurrentUser; const actions = reindexActionsFactory(this.client, callAsCurrentUser); - const service = reindexServiceFactory( - callAsCurrentUser, - actions, - this.log, - this.licensing, - this.apmIndexPatterns - ); + const service = reindexServiceFactory(callAsCurrentUser, actions, this.log, this.licensing); reindexOp = await swallowExceptions(service.processNextStep, this.log)(reindexOp); // Update credential store with most recent state. diff --git a/x-pack/plugins/upgrade_assistant/server/plugin.ts b/x-pack/plugins/upgrade_assistant/server/plugin.ts index b6b9b5390b004..c6da641f4092f 100644 --- a/x-pack/plugins/upgrade_assistant/server/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/server/plugin.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { first } from 'rxjs/operators'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { @@ -21,9 +20,6 @@ import { import { CloudSetup } from '../../cloud/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; -import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; - -import { extractIndexPatterns } from './lib/apm/extract_index_patterns'; import { CredentialStore, credentialStoreFactory } from './lib/reindexing/credential_store'; import { ReindexWorker } from './lib/reindexing'; @@ -31,7 +27,6 @@ import { registerUpgradeAssistantUsageCollector } from './lib/telemetry'; import { versionService } from './lib/version'; import { registerClusterCheckupRoutes } from './routes/cluster_checkup'; import { registerDeprecationLoggingRoutes } from './routes/deprecation_logging'; -import { registerQueryDefaultFieldRoutes } from './routes/query_default_field'; import { registerReindexIndicesRoutes, createReindexWorker } from './routes/reindex_indices'; import { registerTelemetryRoutes } from './routes/telemetry'; import { telemetrySavedObjectType, reindexOperationSavedObjectType } from './saved_object_types'; @@ -41,7 +36,6 @@ import { RouteDependencies } from './types'; interface PluginsSetup { usageCollection: UsageCollectionSetup; licensing: LicensingPluginSetup; - apmOss: APMOSSPluginSetup; features: FeaturesPluginSetup; cloud?: CloudSetup; } @@ -53,7 +47,6 @@ export class UpgradeAssistantServerPlugin implements Plugin { // Properties set at setup private licensing?: LicensingPluginSetup; - private apmOSS?: APMOSSPluginSetup; // Properties set at start private savedObjectsServiceStart?: SavedObjectsServiceStart; @@ -74,10 +67,9 @@ export class UpgradeAssistantServerPlugin implements Plugin { setup( { http, getStartServices, capabilities, savedObjects }: CoreSetup, - { usageCollection, cloud, features, licensing, apmOss: apmOSS }: PluginsSetup + { usageCollection, cloud, features, licensing }: PluginsSetup ) { this.licensing = licensing; - this.apmOSS = apmOSS; savedObjects.registerType(reindexOperationSavedObjectType); savedObjects.registerType(telemetrySavedObjectType); @@ -100,7 +92,6 @@ export class UpgradeAssistantServerPlugin implements Plugin { const dependencies: RouteDependencies = { cloud, router, - apmOSS, credentialStore: this.credentialStore, log: this.logger, getSavedObjectsService: () => { @@ -120,7 +111,6 @@ export class UpgradeAssistantServerPlugin implements Plugin { registerReindexIndicesRoutes(dependencies, this.getWorker.bind(this)); // Bootstrap the needed routes and the collector for the telemetry registerTelemetryRoutes(dependencies); - registerQueryDefaultFieldRoutes(dependencies); if (usageCollection) { getStartServices().then(([{ savedObjects: savedObjectsService, elasticsearch }]) => { @@ -133,7 +123,7 @@ export class UpgradeAssistantServerPlugin implements Plugin { } } - async start({ savedObjects, elasticsearch }: CoreStart) { + start({ savedObjects, elasticsearch }: CoreStart) { this.savedObjectsServiceStart = savedObjects; // The ReindexWorker uses a map of request headers that contain the authentication credentials @@ -143,10 +133,6 @@ export class UpgradeAssistantServerPlugin implements Plugin { // process jobs without the browser staying on the page, but will require that jobs go into // a paused state if no Kibana nodes have the required credentials. - const apmIndexPatterns = extractIndexPatterns( - await this.apmOSS!.config$.pipe(first()).toPromise() - ); - this.worker = createReindexWorker({ credentialStore: this.credentialStore, licensing: this.licensing!, @@ -155,10 +141,9 @@ export class UpgradeAssistantServerPlugin implements Plugin { savedObjects: new SavedObjectsClient( this.savedObjectsServiceStart.createInternalRepository() ), - apmIndexPatterns, }); - this.worker!.start(); + this.worker.start(); } stop(): void { diff --git a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts index 6b831a8e591f7..a5da4741b10eb 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.test.ts @@ -6,7 +6,6 @@ */ import { kibanaResponseFactory } from 'src/core/server'; -import { apmOSSPluginSetupMock } from '../../../../../src/plugins/apm_oss/server/mocks'; import { createMockRouter, MockRouter, routeHandlerContextMock } from './__mocks__/routes.mock'; import { createRequestMock } from './__mocks__/request.mock'; @@ -36,7 +35,6 @@ describe('cluster checkup API', () => { cloud: { isCloudEnabled: true, }, - apmOSS: apmOSSPluginSetupMock.create({ indexPattern: 'apm-*' }), router: mockRouter, }; registerClusterCheckupRoutes(routeDependencies); @@ -64,25 +62,6 @@ describe('cluster checkup API', () => { }); }); - describe('with APM enabled', () => { - it('is provided to getUpgradeAssistantStatus', async () => { - const spy = jest.spyOn(MigrationApis, 'getUpgradeAssistantStatus'); - - MigrationApis.getUpgradeAssistantStatus.mockResolvedValue({ - cluster: [], - indices: [], - nodes: [], - }); - - await routeDependencies.router.getHandler({ - method: 'get', - pathPattern: '/api/upgrade_assistant/status', - })(routeHandlerContextMock, createRequestMock(), kibanaResponseFactory); - - expect(spy.mock.calls[0][2]).toEqual(['apm-*']); - }); - }); - describe('GET /api/upgrade_assistant/reindex/{indexName}.json', () => { it('returns state', async () => { MigrationApis.getUpgradeAssistantStatus.mockResolvedValue({ diff --git a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts index 923fc547071b8..b4dae6ec385b4 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/cluster_checkup.ts @@ -5,21 +5,13 @@ * 2.0. */ -import { first } from 'rxjs/operators'; import { getUpgradeAssistantStatus } from '../lib/es_migration_apis'; import { versionCheckHandlerWrapper } from '../lib/es_version_precheck'; -import { extractIndexPatterns } from '../lib/apm/extract_index_patterns'; import { RouteDependencies } from '../types'; import { reindexActionsFactory } from '../lib/reindexing/reindex_actions'; import { reindexServiceFactory } from '../lib/reindexing'; -export function registerClusterCheckupRoutes({ - cloud, - router, - apmOSS, - licensing, - log, -}: RouteDependencies) { +export function registerClusterCheckupRoutes({ cloud, router, licensing, log }: RouteDependencies) { const isCloudEnabled = Boolean(cloud?.isCloudEnabled); router.get( @@ -39,10 +31,7 @@ export function registerClusterCheckupRoutes({ response ) => { try { - const apmConfig = await apmOSS.config$.pipe(first()).toPromise(); - const indexPatterns = extractIndexPatterns(apmConfig); - - const status = await getUpgradeAssistantStatus(client, isCloudEnabled, indexPatterns); + const status = await getUpgradeAssistantStatus(client, isCloudEnabled); const asCurrentUser = client.asCurrentUser; const reindexActions = reindexActionsFactory(savedObjectsClient, asCurrentUser); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.test.mocks.ts b/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.test.mocks.ts deleted file mode 100644 index 730e750a8ffb6..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.test.mocks.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const mockAddDefaultField = jest.fn(); - -jest.mock('../lib/query_default_field', () => ({ - addDefaultField: mockAddDefaultField, -})); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.test.ts deleted file mode 100644 index 081e959ff08e2..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { kibanaResponseFactory } from 'src/core/server'; -import { createMockRouter, routeHandlerContextMock } from './__mocks__/routes.mock'; -import { createRequestMock } from './__mocks__/request.mock'; -import { mockAddDefaultField } from './query_default_field.test.mocks'; - -jest.mock('../lib/es_version_precheck', () => ({ - versionCheckHandlerWrapper: (a: any) => a, -})); - -import { registerQueryDefaultFieldRoutes } from './query_default_field'; - -describe('add query default field API', () => { - let dependencies: any; - - beforeEach(() => { - dependencies = { - router: createMockRouter(), - }; - mockAddDefaultField.mockClear(); - registerQueryDefaultFieldRoutes(dependencies); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('calls addDefaultField with index, field types, and other fields', async () => { - mockAddDefaultField.mockResolvedValueOnce({ acknowledged: true }); - const resp = await dependencies.router.getHandler({ - method: 'post', - pathPattern: '/api/upgrade_assistant/add_query_default_field/{indexName}', - })( - routeHandlerContextMock, - createRequestMock({ - params: { indexName: 'myIndex' }, - body: { - fieldTypes: ['text', 'boolean'], - otherFields: ['myCustomField'], - }, - }), - kibanaResponseFactory - ); - - expect(mockAddDefaultField).toHaveBeenCalledWith( - routeHandlerContextMock.core.elasticsearch.client, - 'myIndex', - new Set(['text', 'boolean']), - new Set(['myCustomField']) - ); - expect(resp.status).toEqual(200); - expect(resp.payload).toEqual({ acknowledged: true }); - }); - - it('calls addDefaultField with index, field types if other fields is not specified', async () => { - mockAddDefaultField.mockResolvedValueOnce({ acknowledged: true }); - const resp = await dependencies.router.getHandler({ - method: 'post', - pathPattern: '/api/upgrade_assistant/add_query_default_field/{indexName}', - })( - routeHandlerContextMock, - createRequestMock({ - params: { indexName: 'myIndex' }, - body: { - fieldTypes: ['text', 'boolean'], - }, - }), - kibanaResponseFactory - ); - - expect(mockAddDefaultField).toHaveBeenCalledWith( - routeHandlerContextMock.core.elasticsearch.client, - 'myIndex', - new Set(['text', 'boolean']), - undefined - ); - expect(resp.status).toEqual(200); - expect(resp.payload).toEqual({ acknowledged: true }); - }); -}); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.ts b/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.ts deleted file mode 100644 index 5942dda56db9d..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/routes/query_default_field.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { addDefaultField } from '../lib/query_default_field'; -import { versionCheckHandlerWrapper } from '../lib/es_version_precheck'; -import { RouteDependencies } from '../types'; - -/** - * Adds routes for detecting and fixing 6.x Metricbeat indices that need the - * `index.query.default_field` index setting added. - * - * @param server - */ -export function registerQueryDefaultFieldRoutes({ router }: RouteDependencies) { - router.post( - { - path: '/api/upgrade_assistant/add_query_default_field/{indexName}', - validate: { - params: schema.object({ - indexName: schema.string(), - }), - body: schema.object({ - fieldTypes: schema.arrayOf(schema.string()), - otherFields: schema.arrayOf(schema.string(), { defaultValue: undefined }), - }), - }, - }, - versionCheckHandlerWrapper( - async ( - { - core: { - elasticsearch: { client }, - }, - }, - request, - response - ) => { - try { - const { indexName } = request.params; - const { fieldTypes, otherFields } = request.body as { - fieldTypes: string[]; - otherFields?: string[]; - }; - - return response.ok({ - body: await addDefaultField( - client, - indexName, - new Set(fieldTypes), - otherFields ? new Set(otherFields) : undefined - ), - }); - } catch (e) { - const status = e.status || e.statusCode; - if (status === 403) { - return response.forbidden({ body: e }); - } - - throw e; - } - } - ) - ); -} diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts index b74a52db7da16..21dded346bbd3 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts @@ -7,7 +7,6 @@ import { kibanaResponseFactory } from 'src/core/server'; import { licensingMock } from '../../../../licensing/server/mocks'; -import { apmOSSPluginSetupMock } from '../../../../../../src/plugins/apm_oss/server/mocks'; import { createMockRouter, MockRouter, routeHandlerContextMock } from '../__mocks__/routes.mock'; import { createRequestMock } from '../__mocks__/request.mock'; @@ -61,7 +60,6 @@ describe('reindex API', () => { routeDependencies = { credentialStore, router: mockRouter, - apmOSS: apmOSSPluginSetupMock.create(), licensing: licensingMock.createSetup(), }; registerReindexIndicesRoutes(routeDependencies, () => worker); @@ -91,7 +89,9 @@ describe('reindex API', () => { mockReindexService.findReindexOperation.mockResolvedValueOnce({ attributes: { indexName: 'wowIndex', status: ReindexStatus.inProgress }, }); - mockReindexService.detectReindexWarnings.mockResolvedValueOnce([ReindexWarning.apmReindex]); + mockReindexService.detectReindexWarnings.mockResolvedValueOnce([ + ReindexWarning.customTypeName, + ]); const resp = await routeDependencies.router.getHandler({ method: 'get', diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts index bfc794e6493f8..d36f8225ffa3b 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.ts @@ -33,7 +33,6 @@ import { ReindexCannotBeCancelled, ReindexTaskCannotBeDeleted, ReindexTaskFailed, - CannotReindexSystemIndexInCurrent, } from '../../lib/reindexing/error_symbols'; import { reindexHandler } from './reindex_handler'; @@ -45,7 +44,6 @@ interface CreateReindexWorker { credentialStore: CredentialStore; savedObjects: SavedObjectsClient; licensing: LicensingPluginSetup; - apmIndexPatterns: string[]; } export function createReindexWorker({ @@ -54,17 +52,9 @@ export function createReindexWorker({ credentialStore, savedObjects, licensing, - apmIndexPatterns, }: CreateReindexWorker) { const esClient = elasticsearchService.client; - return new ReindexWorker( - savedObjects, - credentialStore, - esClient, - logger, - licensing, - apmIndexPatterns - ); + return new ReindexWorker(savedObjects, credentialStore, esClient, logger, licensing); } const mapAnyErrorToKibanaHttpResponse = (e: any) => { @@ -84,9 +74,6 @@ const mapAnyErrorToKibanaHttpResponse = (e: any) => { case MultipleReindexJobsFound: case ReindexCannotBeCancelled: return kibanaResponseFactory.badRequest({ body: e.message }); - case CannotReindexSystemIndexInCurrent: - // Not implemented (specific to current version) - return kibanaResponseFactory.customError({ body: e.message, statusCode: 501 }); default: // nothing matched } diff --git a/x-pack/plugins/upgrade_assistant/server/types.ts b/x-pack/plugins/upgrade_assistant/server/types.ts index ad450a3fdaf1c..80c60e3f310bc 100644 --- a/x-pack/plugins/upgrade_assistant/server/types.ts +++ b/x-pack/plugins/upgrade_assistant/server/types.ts @@ -9,11 +9,9 @@ import { IRouter, Logger, SavedObjectsServiceStart } from 'src/core/server'; import { CloudSetup } from '../../cloud/server'; import { CredentialStore } from './lib/reindexing/credential_store'; import { LicensingPluginSetup } from '../../licensing/server'; -import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; export interface RouteDependencies { router: IRouter; - apmOSS: APMOSSPluginSetup; credentialStore: CredentialStore; log: Logger; getSavedObjectsService: () => SavedObjectsServiceStart; diff --git a/x-pack/plugins/upgrade_assistant/tsconfig.json b/x-pack/plugins/upgrade_assistant/tsconfig.json index 2f6bdd371ad0d..220ae4d0f0160 100644 --- a/x-pack/plugins/upgrade_assistant/tsconfig.json +++ b/x-pack/plugins/upgrade_assistant/tsconfig.json @@ -19,7 +19,6 @@ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/management/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, - { "path": "../../../src/plugins/apm_oss/tsconfig.json"}, { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, diff --git a/x-pack/test/accessibility/apps/index_lifecycle_management.ts b/x-pack/test/accessibility/apps/index_lifecycle_management.ts index 43fd805a42d37..d6ba222e50eb4 100644 --- a/x-pack/test/accessibility/apps/index_lifecycle_management.ts +++ b/x-pack/test/accessibility/apps/index_lifecycle_management.ts @@ -28,7 +28,10 @@ const TEST_POLICY_ALL_PHASES = { }; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const { common } = getPageObjects(['common']); + const { common, indexLifecycleManagement } = getPageObjects([ + 'common', + 'indexLifecycleManagement', + ]); const retry = getService('retry'); const testSubjects = getService('testSubjects'); const esClient = getService('es'); @@ -55,6 +58,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esClient.ilm.deleteLifecycle({ policy: TEST_POLICY_NAME }); }); + it('Create Policy Form', async () => { + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + return testSubjects.isDisplayed('createPolicyButton'); + }); + + // Navigate to create policy page and take snapshot + await testSubjects.click('createPolicyButton'); + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + return (await testSubjects.getVisibleText('policyTitle')) === 'Create policy'; + }); + + // Fill out form after enabling all phases and take snapshot. + await indexLifecycleManagement.fillNewPolicyForm('testPolicy', true, true, false); + await a11y.testAppSnapshot(); + }); + + it('Send Request Flyout on New Policy Page', async () => { + // Take snapshot of the show request panel + await testSubjects.click('requestButton'); + await a11y.testAppSnapshot(); + + // Close panel and save policy + await testSubjects.click('euiFlyoutCloseButton'); + await indexLifecycleManagement.saveNewPolicy(); + }); + it('List policies view', async () => { await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { await common.navigateToApp('indexLifecycleManagement'); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index 9e1c290d16059..20d2b107dc2cc 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -273,6 +273,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: '*:*', + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.domain: *', // narrow things down to indicators with a domain threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -353,6 +354,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.ip: *', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -422,6 +424,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: 'source.port: 57324', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.ip: *', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ @@ -519,6 +522,7 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', query: '*:*', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', threat_query: '', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module threat_mapping: [ diff --git a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts index 04db9e4544c9a..ddf46926f122a 100644 --- a/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts +++ b/x-pack/test/functional/page_objects/index_lifecycle_management_page.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function IndexLifecycleManagementPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const retry = getService('retry'); return { async sectionHeadingText() { @@ -17,5 +18,40 @@ export function IndexLifecycleManagementPageProvider({ getService }: FtrProvider async createPolicyButton() { return await testSubjects.find('createPolicyButton'); }, + async fillNewPolicyForm( + policyName: string, + warmEnabled: boolean = false, + coldEnabled: boolean = false, + deletePhaseEnabled: boolean = false + ) { + await testSubjects.setValue('policyNameField', policyName); + if (warmEnabled) { + await retry.try(async () => { + await testSubjects.click('enablePhaseSwitch-warm'); + }); + } + if (coldEnabled) { + await retry.try(async () => { + await testSubjects.click('enablePhaseSwitch-cold'); + }); + } + if (deletePhaseEnabled) { + await retry.try(async () => { + await testSubjects.click('enableDeletePhaseButton'); + }); + } + }, + async saveNewPolicy() { + await testSubjects.click('savePolicyButton'); + }, + async createNewPolicyAndSave( + policyName: string, + warmEnabled: boolean = false, + coldEnabled: boolean = false, + deletePhaseEnabled: boolean = false + ) { + await this.fillNewPolicyForm(policyName, warmEnabled, coldEnabled, deletePhaseEnabled); + await this.saveNewPolicy(); + }, }; } diff --git a/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json b/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json new file mode 100644 index 0000000000000..b7de2dba02d19 --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/exceptions/data.json @@ -0,0 +1,23 @@ +{ + "type": "doc", + "value": { + "id": "_aZE5nwBOpWiDweSth_D", + "index": "exceptions-0001", + "source": { + "@timestamp": "2019-09-01T00:41:06.527Z", + "agent": { + "name": "bond" + }, + "user" : [ + { + "name" : "john", + "id" : "c5baec68-e774-46dc-b728-417e71d68444" + }, + { + "name" : "alice", + "id" : "6e831997-deab-4e56-9218-a90ef045556e" + } + ] + } + } +} diff --git a/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json b/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json new file mode 100644 index 0000000000000..e63b86392756f --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/exceptions/mappings.json @@ -0,0 +1,42 @@ +{ + "type": "index", + "value": { + "aliases": { + "exceptions": { + "is_write_index": false + } + }, + "settings": { + "index": { + "refresh_interval": "5s" + } + }, + "index": "exceptions-0001", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "type": "nested", + "properties": { + "first": { + "type": "keyword" + }, + "last": { + "type": "keyword" + } + } + } + } + } + } +} diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js index 4a935b01fde6f..1a7090a3cbdfb 100644 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js @@ -9,7 +9,6 @@ export default function ({ loadTestFile }) { describe('upgrade assistant', function () { this.tags('ciGroup7'); - loadTestFile(require.resolve('./query_default_field')); loadTestFile(require.resolve('./reindexing')); }); } diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/query_default_field.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/query_default_field.js deleted file mode 100644 index 1b579c90cf4c9..0000000000000 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/query_default_field.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; - -export default function ({ getService }) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); - - const indexName = `metricbeat-6.7.0-2019.03.11`; - - describe('add default_field setting', () => { - beforeEach(async () => { - await esArchiver.load('upgrade_assistant/metricbeat'); - }); - - afterEach(async () => { - await esArchiver.unload('upgrade_assistant/metricbeat'); - }); - - it('adds index.query.default_field to metricbeat index', async () => { - const { body } = await supertest - .post(`/api/upgrade_assistant/add_query_default_field/${indexName}`) - .set('kbn-xsrf', 'xxx') - .send({ fieldTypes: ['text', 'keyword', 'ip'], otherFields: ['fields.*'] }) - .expect(200); - expect(body.body.acknowledged).to.be(true); - - // The index.query.default_field setting should now be set - const { body: settingsResp } = await es.indices.getSettings({ index: indexName }); - expect(settingsResp[indexName].settings.index.query.default_field).to.not.be(undefined); - - // Deprecation message should be gone - const { body: uaBody } = await supertest.get('/api/upgrade_assistant/status').expect(200); - const depMessage = uaBody.indices.find( - (dep) => - dep.index === indexName && - dep.message === 'Number of fields exceeds automatic field expansion limit' - ); - expect(depMessage).to.be(undefined); - }); - }); -} diff --git a/yarn.lock b/yarn.lock index a1d95b00b2a0e..4d89446282aa3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2107,6 +2107,14 @@ enabled "2.0.x" kuler "^2.0.0" +"@dsherret/to-absolute-glob@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@dsherret/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1f6475dc8bd974cea07a2daf3864b317b1dd332c" + integrity sha1-H2R13IvZdM6gei2vOGSzF7HdMyw= + dependencies: + is-absolute "^1.0.0" + is-negated-glob "^1.0.0" + "@elastic/apm-rum-core@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@elastic/apm-rum-core/-/apm-rum-core-5.7.0.tgz#2213987285324781e2ebeca607f3a71245da5a84" @@ -3428,6 +3436,10 @@ version "0.0.0" uid "" +"@kbn/docs-utils@link:packages/kbn-docs-utils": + version "0.0.0" + uid "" + "@kbn/es-archiver@link:packages/kbn-es-archiver": version "0.0.0" uid "" @@ -3484,10 +3496,6 @@ version "0.0.0" uid "" -"@kbn/release-notes@link:packages/kbn-release-notes": - version "0.0.0" - uid "" - "@kbn/std@link:packages/kbn-std": version "0.0.0" uid "" @@ -5134,6 +5142,18 @@ dependencies: "@babel/runtime" "^7.10.2" +"@ts-morph/common@~0.7.0": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.7.3.tgz#380020c278e4aa6cecedf362a1157591d1003267" + integrity sha512-M6Tcu0EZDLL8Ht7WAYz7yJfDZ9eArhqR8XZ9Mk3q8jwU6MKFAttrw3JtW4JhneqTz7pZMv4XaimEdXI0E4K4rg== + dependencies: + "@dsherret/to-absolute-glob" "^2.0.2" + fast-glob "^3.2.4" + is-negated-glob "^1.0.0" + mkdirp "^1.0.4" + multimatch "^5.0.0" + typescript "~4.1.2" + "@turf/along@6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/@turf/along/-/along-6.0.1.tgz#595cecdc48fc7fcfa83c940a8e3eb24d4c2e04d4" @@ -10561,6 +10581,11 @@ coa@^2.0.2: chalk "^2.4.1" q "^1.1.2" +code-block-writer@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-10.1.1.tgz#ad5684ed4bfb2b0783c8b131281ae84ee640a42f" + integrity sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw== + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -19991,10 +20016,10 @@ lodash.uniq@4.5.0, lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.11, lodash@4.17.15, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.2.0, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.19, lodash@~4.17.20: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@4.17.11, lodash@4.17.15, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.2.0, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.19, lodash@~4.17.20: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-ok@^0.1.1: version "0.1.1" @@ -21176,6 +21201,17 @@ multimatch@^4.0.0: arrify "^2.0.1" minimatch "^3.0.4" +multimatch@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" + integrity sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA== + dependencies: + "@types/minimatch" "^3.0.3" + array-differ "^3.0.0" + array-union "^2.1.0" + arrify "^2.0.1" + minimatch "^3.0.4" + multiparty@^4.1.2: version "4.2.1" resolved "https://registry.yarnpkg.com/multiparty/-/multiparty-4.2.1.tgz#d9b6c46d8b8deab1ee70c734b0af771dd46e0b13" @@ -28444,6 +28480,15 @@ ts-log@2.1.4: resolved "https://registry.yarnpkg.com/ts-log/-/ts-log-2.1.4.tgz#063c5ad1cbab5d49d258d18015963489fb6fb59a" integrity sha512-P1EJSoyV+N3bR/IWFeAqXzKPZwHpnLY6j7j58mAvewHRipo+BQM2Y1f9Y9BjEQznKwgqqZm7H8iuixmssU7tYQ== +ts-morph@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-9.1.0.tgz#10d2088387c71f3c674f82492a3cec1e3538f0dd" + integrity sha512-sei4u651MBenr27sD6qLDXN3gZ4thiX71E3qV7SuVtDas0uvK2LtgZkIYUf9DKm/fLJ6AB/+yhRJ1vpEBJgy7Q== + dependencies: + "@dsherret/to-absolute-glob" "^2.0.2" + "@ts-morph/common" "~0.7.0" + code-block-writer "^10.1.1" + ts-pnp@^1.1.6: version "1.2.0" resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" @@ -28648,7 +28693,7 @@ typescript-tuple@^2.2.1: dependencies: typescript-compare "^0.0.2" -typescript@4.1.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.5.3, typescript@~3.7.2: +typescript@4.1.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.5.3, typescript@~3.7.2, typescript@~4.1.2: version "4.1.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==